Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:19:28 +08:00
commit 1da7b24c8e
254 changed files with 43797 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"name": "unity-editor-toolkit",
"description": "Complete Unity Editor control toolkit with real-time WebSocket communication, SQLite database integration, and GUID-based persistence for GameObjects, hierarchy, scenes, console, and more",
"version": "0.12.1",
"author": {
"name": "Dev GOM",
"url": "https://github.com/Dev-GOM/claude-code-marketplace"
},
"skills": [
"./skills"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# unity-editor-toolkit
Complete Unity Editor control toolkit with real-time WebSocket communication, SQLite database integration, and GUID-based persistence for GameObjects, hierarchy, scenes, console, and more

1045
plugin.lock.json Normal file

File diff suppressed because it is too large Load Diff

387
skills/SKILL.md Normal file
View File

@@ -0,0 +1,387 @@
---
name: unity-editor-toolkit
description: |
Unity Editor control and automation, WebSocket-based real-time communication. 유니티에디터제어및자동화, WebSocket기반실시간통신.
Features/기능: GameObject control 게임오브젝트제어, Transform manipulation 트랜스폼조작, Component management 컴포넌트관리, Scene management 씬관리, SQLite database integration SQLite데이터베이스통합, GUID-based persistence GUID기반영구식별, Multi-scene synchronization 멀티씬동기화, Command Pattern with Undo/Redo 명령패턴실행취소재실행, Menu execution 메뉴실행, ScriptableObject management 스크립터블오브젝트관리, Array/List manipulation 배열리스트조작, All field types support 모든필드타입지원, Material/Rendering 머티리얼/렌더링, Prefab system 프리팹시스템, Asset Database 애셋데이터베이스, Animation 애니메이션, Physics 물리, Console logging 콘솔로깅, EditorPrefs management 에디터프리퍼런스관리, Editor automation 에디터자동화, Build pipeline 빌드파이프라인, Lighting 라이팅, Camera 카메라, Audio 오디오, Navigation 네비게이션, Particles 파티클, Timeline 타임라인, UI Toolkit, Profiler 프로파일러, Test Runner 테스트러너.
Protocol 프로토콜: JSON-RPC 2.0 over WebSocket (port 9500-9600). 500+ commands 명령어, 25 categories 카테고리. Real-time bidirectional communication 실시간양방향통신.
Security 보안: Defense-in-depth 심층방어 (path traversal protection 경로순회방지, command injection defense 명령어인젝션방어, JSON injection prevention JSON인젝션방지, SQL injection prevention SQL인젝션방지, transaction safety 트랜잭션안전성). Localhost-only connections 로컬호스트전용. Cross-platform 크로스플랫폼 (Windows, macOS, Linux).
---
## Purpose
Unity Editor Toolkit enables comprehensive Unity Editor automation and control from Claude Code. It provides:
- **Extensive Command Coverage**: 500+ commands spanning 25 Unity Editor categories
- **Real-time Communication**: Instant bidirectional WebSocket connection (JSON-RPC 2.0)
- **SQLite Database Integration**: Real-time GameObject synchronization with GUID-based persistence
- **GUID-based Identification**: Persistent GameObject tracking across Unity sessions
- **Multi-scene Support**: Synchronize all loaded scenes simultaneously (1s interval)
- **Command Pattern**: Undo/Redo support for database operations
- **Auto Migration**: Automatic schema migration system
- **Batch Operations**: Efficient bulk inserts, updates, and deletes (500 objects/batch)
- **Menu Execution**: Run Unity Editor menu items programmatically (Window, Assets, Edit, GameObject menus)
- **ScriptableObject Management**: Complete CRUD operations with array/list support and all field types
- **Array/List Operations**: Add, remove, get, clear elements with nested access (`items[0].name`)
- **All Field Types**: Integer, Float, String, Boolean, Vector*, Color, Quaternion, Bounds, AnimationCurve, ObjectReference, and more
- **Nested Property Traversal**: Access deeply nested fields with dot notation and array indices
- **Deep Editor Integration**: GameObject/hierarchy, transforms, components, scenes, materials, prefabs, animation, physics, lighting, build pipeline, and more
- **Security First**: Multi-layer defense against injection attacks (SQL, command, JSON, path traversal) and unauthorized access
- **Production Ready**: Cross-platform support with robust error handling and logging
**Always run scripts with `--help` first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
---
## 📚 문서 우선 원칙 (필수)
**⚠️ CRITICAL**: Unity Editor Toolkit skill을 사용할 때는 **반드시 다음 순서를 따르세요:**
### 1⃣ Reference 문서 확인 (필수)
명령어를 사용하기 전에 **반드시** `skills/references/` 폴더의 해당 문서를 읽으세요:
- **[COMMANDS.md](./references/COMMANDS.md)** - 모든 명령어의 카테고리 및 개요
- **Category-specific docs** - 사용할 명령어의 카테고리 문서:
- [Component Commands](./references/COMMANDS_COMPONENT.md) - comp list/add/remove/enable/disable/get/set/inspect/move-up/move-down/copy
- [GameObject Commands](./references/COMMANDS_GAMEOBJECT_HIERARCHY.md) - go find/create/destroy/set-active/set-parent/get-parent/get-children
- [Transform Commands](./references/COMMANDS_TRANSFORM.md) - tf get/set-position/set-rotation/set-scale
- [Scene Commands](./references/COMMANDS_SCENE.md) - scene current/list/load/new/save/unload/set-active
- [Console Commands](./references/COMMANDS_CONSOLE.md) - console logs/clear
- [EditorPrefs Commands](./references/COMMANDS_PREFS.md) - prefs get/set/delete/list/clear/import
- [Other Categories](./references/COMMANDS.md) - 추가 명령어 카테고리
### 2⃣ `--help` 실행
```bash
# 모든 명령어 확인
cd <unity-project-root> && node .unity-websocket/uw --help
# 특정 명령어의 옵션 확인
cd <unity-project-root> && node .unity-websocket/uw <command> --help
```
### 3⃣ 예제 실행
reference 문서의 **Examples 섹션**을 참고하여 명령어를 실행하세요.
### 4⃣ 소스 코드 읽기 (최후의 수단)
- reference 문서와 --help만으로는 해결 안 될 때만 소스 코드를 읽으세요
- 소스 코드는 컨텍스트 윈도우를 많이 차지하므로 가능하면 피하세요
**이 순서를 무시하면:**
- ❌ 명령어 사용법을 잘못 이해할 수 있음
- ❌ 옵션을 놓쳐서 원하지 않는 결과가 나올 수 있음
- ❌ 컨텍스트 윈도우를 낭비할 수 있음
---
## When to Use
Use Unity Editor Toolkit when you need to:
1. **Automate Unity Editor Tasks**
- Create and manipulate GameObjects, components, and hierarchies
- Configure scenes, materials, and rendering settings
- Control animation, physics, and particle systems
- Manage assets, prefabs, and build pipelines
2. **Real-time Unity Testing**
- Monitor console logs and errors during development
- Query GameObject states and component properties
- Test scene configurations and gameplay logic
- Debug rendering, physics, or animation issues
3. **Batch Operations**
- Create multiple GameObjects with specific configurations
- Apply material/shader changes across multiple objects
- Setup scene hierarchies from specifications
- Automate repetitive Editor tasks
4. **Menu and Editor Automation**
- Execute Unity Editor menu items programmatically (`menu run "Window/General/Console"`)
- Open editor windows and tools via command line
- Automate asset refresh, reimport, and build operations
- Query available menu items with wildcard filtering
5. **ScriptableObject Management**
- Create and configure ScriptableObject assets programmatically
- Read and modify all field types (Vector, Color, Quaternion, AnimationCurve, etc.)
- Manipulate arrays/lists with full CRUD operations
- Access nested properties with array index notation (`items[0].stats.health`)
- Query ScriptableObject types and inspect asset metadata
6. **Database-Driven Workflows**
- Persistent GameObject tracking across Unity sessions with GUID-based identification
- Real-time synchronization of all loaded scenes to SQLite database
- Analytics and querying of GameObject hierarchies and properties
- Undo/Redo support for database operations via Command Pattern
- Efficient batch operations (500 objects/batch) for large scene management
7. **CI/CD Integration**
- Automated builds with platform-specific settings
- Test Runner integration for unit/integration tests
- Asset validation and integrity checks
- Build pipeline automation
## Prerequisites
### Unity Project Setup
1. **Install Unity Editor Toolkit Server Package**
- Via Unity Package Manager (Git URL or local path)
- Requires Unity 2020.3 or higher
- Package location: `skills/assets/unity-package`
2. **Configure WebSocket Server**
- Open Unity menu: `Tools > Unity Editor Toolkit > Server Window`
- Plugin scripts path auto-detected from `~/.claude/plugins/...`
- Click "Install CLI" to build WebSocket server (one-time setup)
- Server starts automatically when Unity Editor opens
3. **Database Setup** (Optional)
- In the Server window, switch to "Database" tab
- Click "Connect" to initialize SQLite database
- Database file location: `{ProjectRoot}/.unity-websocket/unity-editor.db`
- Click "Start Sync" to enable real-time GameObject synchronization (1s interval)
- **GUID Components**: GameObjects are automatically tagged with persistent GUIDs
- **Multi-scene**: All loaded scenes are synchronized automatically
- **Analytics**: View sync stats, database health, and Undo/Redo history
4. **Server Status**
- Port: Auto-assigned from range 9500-9600
- Status file: `{ProjectRoot}/.unity-websocket/server-status.json`
- CLI automatically detects correct port from this file
5. **Dependencies**
- websocket-sharp (install via package installation scripts)
- Newtonsoft.Json (Unity's built-in version)
- Cysharp.UniTask (for async/await database operations)
- SQLite-net (embedded SQLite database)
### Claude Code Plugin
The Unity Editor Toolkit plugin provides CLI commands for Unity Editor control.
## Core Workflow
### 1. Connection
Unity Editor Toolkit CLI automatically:
- Detects Unity project via `.unity-websocket/server-status.json`
- Reads port information from status file (9500-9600 range)
- Connects to WebSocket server if Unity Editor is running
### 2. Execute Commands
⚠️ **Before executing ANY command, check the reference documentation for your command category** (see "📚 문서 우선 원칙" section above).
Unity Editor Toolkit provides 40+ commands across 12+ categories. All commands run from the Unity project root:
```bash
cd <unity-project-root> && node .unity-websocket/uw <command> [options]
```
**Available Categories** (Implemented):
| # | Category | Commands | Reference |
|---|----------|----------|-----------|
| 1 | Connection & Status | 1 | [COMMANDS_CONNECTION_STATUS.md](./references/COMMANDS_CONNECTION_STATUS.md) |
| 2 | GameObject & Hierarchy | 8 | [COMMANDS_GAMEOBJECT_HIERARCHY.md](./references/COMMANDS_GAMEOBJECT_HIERARCHY.md) |
| 3 | Transform | 4 | [COMMANDS_TRANSFORM.md](./references/COMMANDS_TRANSFORM.md) |
| 4 | **Component** ✨ | 10 | [COMMANDS_COMPONENT.md](./references/COMMANDS_COMPONENT.md) |
| 5 | Scene Management | 7 | [COMMANDS_SCENE.md](./references/COMMANDS_SCENE.md) |
| 6 | Asset Database & Editor | 3 | [COMMANDS_EDITOR.md](./references/COMMANDS_EDITOR.md) |
| 7 | Console & Logging | 2 | [COMMANDS_CONSOLE.md](./references/COMMANDS_CONSOLE.md) |
| 8 | EditorPrefs Management | 6 | [COMMANDS_PREFS.md](./references/COMMANDS_PREFS.md) |
| 9 | Wait Commands | 4 | [COMMANDS_WAIT.md](./references/COMMANDS_WAIT.md) |
| 10 | Chain Commands | 2 | [COMMANDS_CHAIN.md](./references/COMMANDS_CHAIN.md) |
| 11 | Menu Execution | 2 | [COMMANDS_MENU.md](./references/COMMANDS_MENU.md) |
| 12 | Asset Management | 9 | [COMMANDS_ASSET.md](./references/COMMANDS_ASSET.md) |
**Usage:**
```bash
cd <unity-project-root> && node .unity-websocket/uw <command> [options]
```
**Required: Check Documentation**
```bash
# 1. 먼저 명령어 카테고리의 reference 문서를 읽으세요
# 예: Component 명령어 사용 → skills/references/COMMANDS_COMPONENT.md 읽기
# 2. --help로 명령어 옵션 확인
cd <unity-project-root> && node .unity-websocket/uw --help
cd <unity-project-root> && node .unity-websocket/uw <command> --help
# 3. reference 문서의 예제를 참고하여 실행
```
**📖 Complete Documentation by Category**
**Required Reading**: Before using any command, read the **Category-specific reference document**:
- 🔴 **MUST READ FIRST** - [COMMANDS.md](./references/COMMANDS.md) - Overview and command roadmap
- 🔴 **MUST READ** - Category-specific docs (links in the table above)
- [Component Commands](./references/COMMANDS_COMPONENT.md) - **NEW**: comp list/add/remove/enable/disable/get/set/inspect/move-up/move-down/copy
- [GameObject Commands](./references/COMMANDS_GAMEOBJECT_HIERARCHY.md) - go find/create/destroy/set-active/set-parent/get-parent/get-children
- [Transform Commands](./references/COMMANDS_TRANSFORM.md) - tf get/set-position/set-rotation/set-scale
- [Scene Commands](./references/COMMANDS_SCENE.md) - scene current/list/load/new/save/unload/set-active
- [Console Commands](./references/COMMANDS_CONSOLE.md) - console logs/clear
- [EditorPrefs Commands](./references/COMMANDS_PREFS.md) - prefs get/set/delete/list/clear/import
- [Other Categories](./references/COMMANDS.md) - Full list with all categories
### 3. Check Connection Status
```bash
# Verify WebSocket connection
cd <unity-project-root> && node .unity-websocket/uw status
# Use custom port
cd <unity-project-root> && node .unity-websocket/uw --port 9301 status
```
### 4. Complex Workflows
**Create and configure GameObject:**
```bash
cd <unity-project-root> && node .unity-websocket/uw go create "Enemy" && \
cd <unity-project-root> && node .unity-websocket/uw tf set-position "Enemy" "10,0,5" && \
cd <unity-project-root> && node .unity-websocket/uw tf set-rotation "Enemy" "0,45,0"
```
**Load scene and activate GameObject:**
```bash
cd <unity-project-root> && node .unity-websocket/uw scene load "Level1" && \
cd <unity-project-root> && node .unity-websocket/uw go set-active "Boss" true
```
**Batch GameObject creation:**
```bash
for i in {1..10}; do
cd <unity-project-root> && node .unity-websocket/uw go create "Cube_$i" && \
cd <unity-project-root> && node .unity-websocket/uw tf set-position "Cube_$i" "$i,0,0"
done
```
**Wait for compilation then execute:**
```bash
# Make code changes, then wait for compilation to finish
cd <unity-project-root> && node .unity-websocket/uw wait compile && \
cd <unity-project-root> && node .unity-websocket/uw editor refresh
```
**Chain multiple commands sequentially:**
```bash
# Execute commands from JSON file
cd <unity-project-root> && node .unity-websocket/uw chain execute commands.json
# Execute commands inline
cd <unity-project-root> && node .unity-websocket/uw chain exec \
"GameObject.Create:name=Player" \
"GameObject.SetActive:instanceId=123,active=true"
# Continue execution even if some commands fail
cd <unity-project-root> && node .unity-websocket/uw chain exec \
"Editor.Refresh" \
"GameObject.Find:path=InvalidPath" \
"Console.Clear" \
--continue-on-error
```
**CI/CD Pipeline workflow:**
```bash
#!/bin/bash
cd /path/to/unity/project
# Cleanup
node .unity-websocket/uw.js chain exec "Console.Clear" "Editor.Refresh"
# Wait for compilation
node .unity-websocket/uw.js wait compile
# Run tests (example)
node .unity-websocket/uw.js chain exec \
"Scene.Load:name=TestScene" \
"GameObject.Find:path=TestRunner" \
"Console.Clear"
```
## Best Practices
1. **Always Verify Connection**
- Run `cd <unity-project-root> && node .unity-websocket/uw status` before executing commands
- Ensure Unity Editor is running and server component is active
2. **Use Hierarchical Paths**
- Prefer full paths for nested GameObjects: `"Environment/Terrain/Trees"`
- Avoids ambiguity when multiple GameObjects share the same name
3. **Monitor Console Logs**
- Use `cd <unity-project-root> && node .unity-websocket/uw console logs --errors-only` to catch errors during automation
- Clear console before running automation scripts for clean logs
4. **Batch Operations Carefully**
- Add delays between commands if creating many GameObjects
- Consider Unity Editor performance limitations
5. **Connection Management**
- Unity Editor Toolkit uses localhost-only connections (127.0.0.1)
- Port range limited to 9500-9600 to avoid conflicts with other tools
6. **Error Handling**
- Commands return JSON-RPC error responses for invalid operations
- Check exit codes and error messages in automation scripts
7. **Port Management**
- Default port 9500 works for most projects
- Use `--port` flag if running multiple Unity Editor instances
- Plugin avoids conflicts with Browser Pilot (9222-9322) and Blender Toolkit (9400-9500)
8. **Wait Commands Usage**
- Use `wait compile` after making code changes to ensure compilation finishes
- Use `wait playmode enter/exit` for play mode synchronization in automated tests
- Use `wait sleep` to add delays between commands when needed
- Note: Wait commands have delayed responses (default 5-minute timeout)
- Domain reload automatically cancels all pending wait requests
9. **Chain Commands Best Practices**
- Use chain for sequential command execution with automatic error handling
- Default behavior: stop on first error (use `--continue-on-error` to override)
- Wait commands are NOT supported in chain (use separate wait commands)
- Use JSON files for complex multi-step workflows
- Use inline exec for quick command sequences
10. **Development Roadmap Awareness**
- **Phase 1 (Current)**: GameObject, Transform, Scene, Console, Wait, Chain - 26 commands
- **Phase 2+**: Component, Material, Prefab, Animation, Physics, Build - 474+ commands coming soon
- See full roadmap in [COMMANDS.md](./references/COMMANDS.md)
## References
Detailed documentation available in the `references/` folder:
- **[QUICKSTART.md](../QUICKSTART.md)** - Quick setup and first commands (English)
- **[QUICKSTART.ko.md](../QUICKSTART.ko.md)** - Quick setup guide (Korean)
- **[COMMANDS.md](./references/COMMANDS.md)** - Complete 500+ command roadmap (English)
- **Implemented Command Categories:**
- [Connection & Status](./references/COMMANDS_CONNECTION_STATUS.md)
- [GameObject & Hierarchy](./references/COMMANDS_GAMEOBJECT_HIERARCHY.md)
- [Transform](./references/COMMANDS_TRANSFORM.md)
- [Scene Management](./references/COMMANDS_SCENE.md)
- [Asset Database & Editor](./references/COMMANDS_EDITOR.md)
- [Console & Logging](./references/COMMANDS_CONSOLE.md)
- [EditorPrefs Management](./references/COMMANDS_PREFS.md)
- [Wait Commands](./references/COMMANDS_WAIT.md)
- [Chain Commands](./references/COMMANDS_CHAIN.md)
- **[API_COMPATIBILITY.md](../API_COMPATIBILITY.md)** - Unity version compatibility (2020.3 - Unity 6)
- **[TEST_GUIDE.md](../TEST_GUIDE.md)** - Unity C# server testing guide (English)
- **[TEST_GUIDE.ko.md](../TEST_GUIDE.ko.md)** - Unity C# server testing guide (Korean)
Unity C# server package available in `assets/unity-package/` - install via Unity Package Manager once released.
---
**Status**: 🧪 Experimental - Phase 1 (26 commands implemented)
**Unity Version Support**: 2020.3 - Unity 6
**Protocol**: JSON-RPC 2.0 over WebSocket
**Port Range**: 9500-9600 (auto-assigned)

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: deb67eda7d4bc4d4e85a0ae423f65903
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 37d6465efcdcc9349bf098e0b33cfffc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,38 @@
using System;
namespace UnityEditorToolkit.Editor.Attributes
{
/// <summary>
/// Marks a static method as executable via CLI
/// Only methods with this attribute can be executed through Editor.Execute command
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class ExecutableMethodAttribute : Attribute
{
/// <summary>
/// CLI command name (e.g., "reinstall-cli")
/// </summary>
public string CommandName { get; }
/// <summary>
/// Description of what this method does
/// </summary>
public string Description { get; }
/// <summary>
/// Mark a method as executable via CLI
/// </summary>
/// <param name="commandName">CLI command name (kebab-case recommended)</param>
/// <param name="description">Human-readable description</param>
public ExecutableMethodAttribute(string commandName, string description = "")
{
if (string.IsNullOrWhiteSpace(commandName))
{
throw new ArgumentException("Command name cannot be null or empty", nameof(commandName));
}
CommandName = commandName;
Description = description;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f459669cdec8d5b469285409d8ea3467

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9d252d66841b3354c9e5d3a0b149edbc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a9809e375b6992d4fab11953cd09b301
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,157 @@
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Commands
{
/// <summary>
/// Command Pattern 기본 추상 클래스
/// 공통 기능 구현
/// </summary>
public abstract class CommandBase : ICommand
{
#region ICommand Properties
public string CommandId { get; protected set; }
public string CommandName { get; protected set; }
public DateTime ExecutedAt { get; protected set; }
public virtual bool CanPersist => true;
#endregion
#region Constructor
protected CommandBase(string commandName)
{
CommandId = Guid.NewGuid().ToString();
CommandName = commandName;
ExecutedAt = DateTime.UtcNow;
}
#endregion
#region Abstract Methods
/// <summary>
/// 실제 실행 로직 (파생 클래스에서 구현)
/// </summary>
protected abstract UniTask<bool> OnExecuteAsync();
/// <summary>
/// 실제 Undo 로직 (파생 클래스에서 구현)
/// </summary>
protected abstract UniTask<bool> OnUndoAsync();
/// <summary>
/// 실제 Redo 로직 (파생 클래스에서 구현)
/// </summary>
protected abstract UniTask<bool> OnRedoAsync();
#endregion
#region ICommand Implementation
public async UniTask<bool> ExecuteAsync()
{
try
{
ToolkitLogger.LogDebug("Command", $" Executing: {CommandName}");
ExecutedAt = DateTime.UtcNow;
bool result = await OnExecuteAsync();
if (result)
{
ToolkitLogger.LogDebug("Command", $" Executed successfully: {CommandName}");
}
else
{
ToolkitLogger.LogWarning("Command", $" Execution failed: {CommandName}");
}
return result;
}
catch (Exception ex)
{
ToolkitLogger.LogError("Command", $" Exception during execution: {CommandName}\n{ex.Message}");
return false;
}
}
public async UniTask<bool> UndoAsync()
{
try
{
ToolkitLogger.LogDebug("Command", $" Undoing: {CommandName}");
bool result = await OnUndoAsync();
if (result)
{
ToolkitLogger.LogDebug("Command", $" Undo successful: {CommandName}");
}
else
{
ToolkitLogger.LogWarning("Command", $" Undo failed: {CommandName}");
}
return result;
}
catch (Exception ex)
{
ToolkitLogger.LogError("Command", $" Exception during undo: {CommandName}\n{ex.Message}");
return false;
}
}
public async UniTask<bool> RedoAsync()
{
try
{
ToolkitLogger.LogDebug("Command", $" Redoing: {CommandName}");
bool result = await OnRedoAsync();
if (result)
{
ToolkitLogger.LogDebug("Command", $" Redo successful: {CommandName}");
}
else
{
ToolkitLogger.LogWarning("Command", $" Redo failed: {CommandName}");
}
return result;
}
catch (Exception ex)
{
ToolkitLogger.LogError("Command", $" Exception during redo: {CommandName}\n{ex.Message}");
return false;
}
}
#endregion
#region Serialization
public virtual string Serialize()
{
// 기본 직렬화 (파생 클래스에서 오버라이드 가능)
return JsonUtility.ToJson(new CommandData
{
commandId = CommandId,
commandName = CommandName,
executedAt = ExecutedAt.ToString("o")
});
}
public virtual void Deserialize(string json)
{
// 기본 역직렬화 (파생 클래스에서 오버라이드 가능)
var data = JsonUtility.FromJson<CommandData>(json);
CommandId = data.commandId;
CommandName = data.commandName;
ExecutedAt = DateTime.Parse(data.executedAt);
}
#endregion
#region Serialization Data
[Serializable]
protected class CommandData
{
public string commandId;
public string commandName;
public string executedAt;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4cc7fda14659f4345be98770ea61234e

View File

@@ -0,0 +1,58 @@
using System;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Commands
{
/// <summary>
/// Command Factory
/// 데이터베이스에서 로드한 명령을 적절한 Command 인스턴스로 복원
/// </summary>
public static class CommandFactory
{
/// <summary>
/// command_type과 command_data로부터 ICommand 인스턴스 생성
/// </summary>
/// <param name="commandType">Command 타입 이름 (예: "CreateGameObjectCommand")</param>
/// <param name="commandData">직렬화된 JSON 데이터</param>
/// <returns>복원된 ICommand 인스턴스, 실패 시 null</returns>
public static ICommand CreateFromDatabase(string commandType, string commandData)
{
try
{
switch (commandType)
{
case "CreateGameObjectCommand":
return CreateGameObjectCommand.FromJson(commandData);
case "TransformChangeCommand":
return TransformChangeCommand.FromJson(commandData);
// DeleteGameObjectCommand는 CanPersist = false이므로 데이터베이스에 저장되지 않음
default:
ToolkitLogger.LogWarning("CommandFactory", $" 알 수 없는 Command 타입: {commandType}");
return null;
}
}
catch (Exception ex)
{
ToolkitLogger.LogError("CommandFactory", $" Command 복원 실패 - Type: {commandType}, Error: {ex.Message}");
return null;
}
}
/// <summary>
/// Command 타입이 지원되는지 확인
/// </summary>
public static bool IsSupported(string commandType)
{
return commandType switch
{
"CreateGameObjectCommand" => true,
"TransformChangeCommand" => true,
_ => false
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d6bb958df2f6a242a02c7274b376c67

View File

@@ -0,0 +1,514 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Commands
{
/// <summary>
/// Command History 관리자
/// Undo/Redo 스택 관리 및 세션 간 영속성
/// </summary>
public class CommandHistory
{
#region Fields
private readonly Stack<ICommand> undoStack;
private readonly Stack<ICommand> redoStack;
private readonly DatabaseManager databaseManager;
private const int MaxHistorySize = 100; // 최대 100개 명령 기록
#endregion
#region Properties
/// <summary>
/// Undo 가능한 명령 개수
/// </summary>
public int UndoCount => undoStack.Count;
/// <summary>
/// Redo 가능한 명령 개수
/// </summary>
public int RedoCount => redoStack.Count;
/// <summary>
/// Undo 가능 여부
/// </summary>
public bool CanUndo => undoStack.Count > 0;
/// <summary>
/// Redo 가능 여부
/// </summary>
public bool CanRedo => redoStack.Count > 0;
#endregion
#region Events
/// <summary>
/// History 변경 이벤트 (UI 업데이트용)
/// </summary>
public event Action OnHistoryChanged;
#endregion
#region Constructor
public CommandHistory(DatabaseManager databaseManager)
{
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
undoStack = new Stack<ICommand>();
redoStack = new Stack<ICommand>();
ToolkitLogger.LogDebug("CommandHistory", "생성 완료.");
}
#endregion
#region Execute Command
/// <summary>
/// 명령 실행 및 히스토리 추가
/// </summary>
public async UniTask<bool> ExecuteCommandAsync(ICommand command)
{
if (command == null)
{
throw new ArgumentNullException(nameof(command));
}
// 명령 실행
bool success = await command.ExecuteAsync();
if (success)
{
// Undo 스택에 추가
undoStack.Push(command);
// Redo 스택 초기화 (새로운 명령이 실행되면 Redo 불가)
redoStack.Clear();
// 히스토리 크기 제한
TrimHistory();
// 데이터베이스에 저장 (선택적)
if (command.CanPersist && databaseManager.IsConnected)
{
await PersistCommandAsync(command);
}
// 이벤트 발생
OnHistoryChanged?.Invoke();
ToolkitLogger.LogDebug("CommandHistory", $" 명령 실행 및 추가: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
}
return success;
}
/// <summary>
/// 이미 실행된 명령을 히스토리에 기록 (실행 없이 기록만)
/// </summary>
public async UniTask RecordCommandAsync(ICommand command)
{
if (command == null)
{
throw new ArgumentNullException(nameof(command));
}
// Undo 스택에 추가 (이미 실행됨)
undoStack.Push(command);
// Redo 스택 초기화 (새로운 명령이 기록되면 Redo 불가)
redoStack.Clear();
// 히스토리 크기 제한
TrimHistory();
// 데이터베이스에 저장 (선택적)
if (command.CanPersist && databaseManager.IsConnected)
{
await PersistCommandAsync(command);
}
// 이벤트 발생
OnHistoryChanged?.Invoke();
ToolkitLogger.LogDebug("CommandHistory", $" 명령 기록: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
}
#endregion
#region Undo/Redo
/// <summary>
/// Undo 실행
/// </summary>
public async UniTask<bool> UndoAsync()
{
if (!CanUndo)
{
ToolkitLogger.LogWarning("CommandHistory", "Undo 불가능 - 스택이 비어있습니다.");
return false;
}
var command = undoStack.Pop();
bool success = await command.UndoAsync();
if (success)
{
// Redo 스택에 추가
redoStack.Push(command);
// 이벤트 발생
OnHistoryChanged?.Invoke();
ToolkitLogger.LogDebug("CommandHistory", $" Undo 완료: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
}
else
{
// 실패 시 다시 Undo 스택에 추가
undoStack.Push(command);
ToolkitLogger.LogError("CommandHistory", $" Undo 실패: {command.CommandName}");
}
return success;
}
/// <summary>
/// Undo 실행 (동기 - WebSocket 핸들러용)
/// </summary>
/// <remarks>
/// WebSocket 핸들러에서 결과를 반환받기 위한 동기 메서드입니다.
/// 내부적으로 UndoAsync()를 동기 호출합니다.
/// </remarks>
public bool Undo()
{
return UndoAsync().GetAwaiter().GetResult();
}
/// <summary>
/// Redo 실행
/// </summary>
public async UniTask<bool> RedoAsync()
{
if (!CanRedo)
{
ToolkitLogger.LogWarning("CommandHistory", "Redo 불가능 - 스택이 비어있습니다.");
return false;
}
var command = redoStack.Pop();
bool success = await command.RedoAsync();
if (success)
{
// Undo 스택에 추가
undoStack.Push(command);
// 이벤트 발생
OnHistoryChanged?.Invoke();
ToolkitLogger.LogDebug("CommandHistory", $" Redo 완료: {command.CommandName} (Undo: {UndoCount}, Redo: {RedoCount})");
}
else
{
// 실패 시 다시 Redo 스택에 추가
redoStack.Push(command);
ToolkitLogger.LogError("CommandHistory", $" Redo 실패: {command.CommandName}");
}
return success;
}
/// <summary>
/// Redo 실행 (동기 - WebSocket 핸들러용)
/// </summary>
/// <remarks>
/// WebSocket 핸들러에서 결과를 반환받기 위한 동기 메서드입니다.
/// 내부적으로 RedoAsync()를 동기 호출합니다.
/// </remarks>
public bool Redo()
{
return RedoAsync().GetAwaiter().GetResult();
}
/// <summary>
/// 다음 Undo할 명령 확인 (스택에서 제거 안함)
/// </summary>
public ICommand PeekUndo()
{
return CanUndo ? undoStack.Peek() : null;
}
/// <summary>
/// 다음 Redo할 명령 확인 (스택에서 제거 안함)
/// </summary>
public ICommand PeekRedo()
{
return CanRedo ? redoStack.Peek() : null;
}
/// <summary>
/// Undo 스택 목록 가져오기
/// </summary>
public List<ICommand> GetUndoStack(int limit = 10)
{
var result = new List<ICommand>();
var temp = new Stack<ICommand>();
int count = 0;
while (undoStack.Count > 0 && count < limit)
{
var cmd = undoStack.Pop();
temp.Push(cmd);
result.Add(cmd);
count++;
}
// 복원
while (temp.Count > 0)
{
undoStack.Push(temp.Pop());
}
return result;
}
/// <summary>
/// Redo 스택 목록 가져오기
/// </summary>
public List<ICommand> GetRedoStack(int limit = 10)
{
var result = new List<ICommand>();
var temp = new Stack<ICommand>();
int count = 0;
while (redoStack.Count > 0 && count < limit)
{
var cmd = redoStack.Pop();
temp.Push(cmd);
result.Add(cmd);
count++;
}
// 복원
while (temp.Count > 0)
{
redoStack.Push(temp.Pop());
}
return result;
}
#endregion
#region History Management
/// <summary>
/// 전체 히스토리 초기화
/// </summary>
public void Clear()
{
undoStack.Clear();
redoStack.Clear();
OnHistoryChanged?.Invoke();
ToolkitLogger.LogDebug("CommandHistory", "히스토리 초기화 완료.");
}
/// <summary>
/// 히스토리 크기 제한
/// </summary>
private void TrimHistory()
{
if (undoStack.Count > MaxHistorySize)
{
// 성능 개선: 리스트로 변환 후 트림하고 스택 재구성
var list = undoStack.ToList();
list.RemoveRange(0, list.Count - MaxHistorySize);
undoStack.Clear();
for (int i = list.Count - 1; i >= 0; i--)
{
undoStack.Push(list[i]);
}
ToolkitLogger.LogDebug("CommandHistory", $" 히스토리 크기 제한 적용: {undoStack.Count}개 유지");
}
}
/// <summary>
/// 최근 명령 목록 가져오기 (UI 표시용)
/// </summary>
public List<string> GetRecentCommands(int count = 10)
{
var commands = new List<string>();
var temp = new Stack<ICommand>();
// Undo 스택에서 가져오기
int retrievedCount = 0;
while (undoStack.Count > 0 && retrievedCount < count)
{
var cmd = undoStack.Pop();
temp.Push(cmd);
commands.Add($"{cmd.ExecutedAt:HH:mm:ss} - {cmd.CommandName}");
retrievedCount++;
}
// 복원
while (temp.Count > 0)
{
undoStack.Push(temp.Pop());
}
return commands;
}
#endregion
#region Database Persistence
/// <summary>
/// 명령을 데이터베이스에 저장
/// </summary>
private async UniTask PersistCommandAsync(ICommand command)
{
try
{
if (!databaseManager.IsConnected || databaseManager.Connector == null)
{
return;
}
// JSON 직렬화
string json = command.Serialize();
// SQL INSERT (SQLite 문법)
string sql = @"
INSERT INTO command_history (
command_id, command_name, command_type,
command_data, executed_at, executed_by
)
VALUES (?, ?, ?, ?, ?, ?);";
await UniTask.RunOnThreadPool(() =>
{
var connection = databaseManager.Connector.Connection;
connection.Execute(sql,
command.CommandId,
command.CommandName,
command.GetType().Name,
json,
command.ExecutedAt.ToString("o"), // ISO 8601 format
"EditorUI" // 실행 주체 구분
);
});
ToolkitLogger.LogDebug("CommandHistory", $" 명령 저장 완료: {command.CommandName}");
}
catch (Exception ex)
{
ToolkitLogger.LogError("CommandHistory", $" 명령 저장 실패: {ex.Message}");
}
}
/// <summary>
/// 데이터베이스에서 히스토리 로드 (세션 복원)
/// </summary>
public async UniTask<int> LoadHistoryFromDatabaseAsync(DateTime since)
{
try
{
if (!databaseManager.IsConnected || databaseManager.Connector == null)
{
ToolkitLogger.LogWarning("CommandHistory", "데이터베이스 연결되지 않음 - 히스토리 로드 불가.");
return 0;
}
// SQL SELECT (SQLite 문법)
string sql = @"
SELECT command_id, command_name, command_type, command_data, executed_at
FROM command_history
WHERE executed_at >= ?
ORDER BY executed_at ASC
LIMIT 100";
int loadedCount = 0;
// DB 쿼리는 백그라운드 스레드에서 실행
List<CommandHistoryRecord> records = null;
await UniTask.RunOnThreadPool(() =>
{
var connection = databaseManager.Connector.Connection;
records = connection.Query<CommandHistoryRecord>(sql, since.ToString("o")).ToList();
});
// Command 복원은 메인 스레드에서 실행 (Unity API 호출 가능)
await UniTask.SwitchToMainThread();
foreach (var record in records)
{
// CommandFactory를 사용하여 Command 복원
var command = CommandFactory.CreateFromDatabase(record.command_type, record.command_data);
if (command != null)
{
// Undo 스택에 추가 (실행 완료된 명령)
undoStack.Push(command);
loadedCount++;
ToolkitLogger.LogDebug("CommandHistory", $" Command 복원: {command.CommandName} (Type: {record.command_type})");
}
else
{
ToolkitLogger.LogWarning("CommandHistory", $" Command 복원 실패 - Type: {record.command_type}, ID: {record.command_id}");
}
}
ToolkitLogger.LogDebug("CommandHistory", $" 히스토리 로드 완료: {loadedCount}개");
return loadedCount;
}
catch (Exception ex)
{
ToolkitLogger.LogError("CommandHistory", $" 히스토리 로드 실패: {ex.Message}");
return 0;
}
}
/// <summary>
/// Command History 레코드 (SQLite 쿼리 결과용)
/// </summary>
private class CommandHistoryRecord
{
public string command_id { get; set; }
public string command_name { get; set; }
public string command_type { get; set; }
public string command_data { get; set; }
public string executed_at { get; set; }
}
#endregion
#region Status
/// <summary>
/// 히스토리 상태 정보
/// </summary>
public HistoryStatus GetStatus()
{
return new HistoryStatus
{
UndoCount = UndoCount,
RedoCount = RedoCount,
CanUndo = CanUndo,
CanRedo = CanRedo,
MaxHistorySize = MaxHistorySize
};
}
#endregion
}
#region Status Struct
public struct HistoryStatus
{
public int UndoCount;
public int RedoCount;
public bool CanUndo;
public bool CanRedo;
public int MaxHistorySize;
public override string ToString()
{
return $"[HistoryStatus] Undo: {UndoCount}, Redo: {RedoCount}, Max: {MaxHistorySize}";
}
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b79709016c210a441ad295d149fe3734

View File

@@ -0,0 +1,187 @@
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Commands
{
/// <summary>
/// GameObject 생성 명령
/// </summary>
public class CreateGameObjectCommand : CommandBase
{
#region Fields
private readonly string gameObjectName;
private readonly Vector3 position;
private readonly Quaternion rotation;
private readonly int parentInstanceId;
private int createdInstanceId;
#endregion
#region Constructor
public CreateGameObjectCommand(
string gameObjectName,
Vector3 position = default,
Quaternion rotation = default,
GameObject parent = null)
: base($"Create GameObject: {gameObjectName}")
{
this.gameObjectName = gameObjectName;
this.position = position;
this.rotation = rotation != default ? rotation : Quaternion.identity;
parentInstanceId = parent != null ? parent.GetInstanceID() : 0;
createdInstanceId = 0;
}
/// <summary>
/// 이미 생성된 GameObject로부터 Command 생성 (중복 생성 방지)
/// </summary>
public static CreateGameObjectCommand CreateFromExisting(GameObject existingObject, GameObject parent = null)
{
if (existingObject == null)
throw new ArgumentNullException(nameof(existingObject));
var command = new CreateGameObjectCommand(
existingObject.name,
existingObject.transform.position,
existingObject.transform.rotation,
parent
);
// 이미 생성된 객체의 InstanceID 저장
command.createdInstanceId = existingObject.GetInstanceID();
return command;
}
#endregion
#region Command Implementation
protected override async UniTask<bool> OnExecuteAsync()
{
try
{
// GameObject 생성
var go = new GameObject(gameObjectName);
go.transform.position = position;
go.transform.rotation = rotation;
// 부모 설정
if (parentInstanceId != 0)
{
var parent = UnityEditor.EditorUtility.InstanceIDToObject(parentInstanceId) as GameObject;
if (parent != null)
{
go.transform.SetParent(parent.transform, true);
}
}
// 생성된 GameObject ID 저장
createdInstanceId = go.GetInstanceID();
ToolkitLogger.LogDebug("CreateGameObjectCommand", $" GameObject 생성: {gameObjectName} (ID: {createdInstanceId})");
await UniTask.Yield();
return true;
}
catch (Exception ex)
{
ToolkitLogger.LogError("CreateGameObjectCommand", $" 생성 실패: {ex.Message}");
return false;
}
}
protected override async UniTask<bool> OnUndoAsync()
{
try
{
// 생성된 GameObject 삭제
var go = UnityEditor.EditorUtility.InstanceIDToObject(createdInstanceId) as GameObject;
if (go != null)
{
UnityEngine.Object.DestroyImmediate(go);
ToolkitLogger.LogDebug("CreateGameObjectCommand", $" GameObject 삭제 (Undo): {gameObjectName}");
}
else
{
ToolkitLogger.LogWarning("CreateGameObjectCommand", $" GameObject를 찾을 수 없음: {gameObjectName}");
}
await UniTask.Yield();
return true;
}
catch (Exception ex)
{
ToolkitLogger.LogError("CreateGameObjectCommand", $" Undo 실패: {ex.Message}");
return false;
}
}
protected override async UniTask<bool> OnRedoAsync()
{
// Redo는 Execute와 동일
return await OnExecuteAsync();
}
#endregion
#region Serialization
public override string Serialize()
{
return JsonUtility.ToJson(new CreateGameObjectData
{
commandId = CommandId,
commandName = CommandName,
executedAt = ExecutedAt.ToString("o"),
gameObjectName = gameObjectName,
position = position,
rotation = rotation,
parentInstanceId = parentInstanceId,
createdInstanceId = createdInstanceId
});
}
/// <summary>
/// JSON에서 Command 복원 (세션 영속성용)
/// </summary>
public static CreateGameObjectCommand FromJson(string json)
{
var data = JsonUtility.FromJson<CreateGameObjectData>(json);
// 부모 GameObject 찾기
GameObject parent = null;
if (data.parentInstanceId != 0)
{
parent = UnityEditor.EditorUtility.InstanceIDToObject(data.parentInstanceId) as GameObject;
}
// 새 인스턴스 생성
var command = new CreateGameObjectCommand(
data.gameObjectName,
data.position,
data.rotation,
parent
);
// 메타데이터 복원
command.CommandId = data.commandId;
command.CommandName = data.commandName;
command.ExecutedAt = DateTime.Parse(data.executedAt);
command.createdInstanceId = data.createdInstanceId;
return command;
}
[Serializable]
private class CreateGameObjectData
{
public string commandId;
public string commandName;
public string executedAt;
public string gameObjectName;
public Vector3 position;
public Quaternion rotation;
public int parentInstanceId;
public int createdInstanceId;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b61e4d05723fa33478874aad39fed96d

View File

@@ -0,0 +1,156 @@
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Commands
{
/// <summary>
/// GameObject 삭제 명령
/// 삭제 전 상태를 저장하여 Undo 지원
/// </summary>
public class DeleteGameObjectCommand : CommandBase
{
#region Fields
private readonly int gameObjectInstanceId;
private readonly string gameObjectName;
private readonly Vector3 position;
private readonly Quaternion rotation;
private readonly Vector3 scale;
private readonly int parentInstanceId;
private readonly int siblingIndex;
private GameObject deletedGameObject; // Undo용 참조 보관
#endregion
#region Constructor
public DeleteGameObjectCommand(GameObject gameObject)
: base($"Delete GameObject: {gameObject.name}")
{
gameObjectInstanceId = gameObject.GetInstanceID();
gameObjectName = gameObject.name;
position = gameObject.transform.position;
rotation = gameObject.transform.rotation;
scale = gameObject.transform.localScale;
parentInstanceId = gameObject.transform.parent != null
? gameObject.transform.parent.gameObject.GetInstanceID()
: 0;
siblingIndex = gameObject.transform.GetSiblingIndex();
}
#endregion
#region Command Implementation
protected override async UniTask<bool> OnExecuteAsync()
{
try
{
var go = UnityEditor.EditorUtility.InstanceIDToObject(gameObjectInstanceId) as GameObject;
if (go == null)
{
ToolkitLogger.LogWarning("DeleteGameObjectCommand", $" GameObject를 찾을 수 없음: {gameObjectName}");
return false;
}
// Undo를 위해 참조 보관 (비활성화)
deletedGameObject = go;
go.SetActive(false);
go.hideFlags = HideFlags.HideInHierarchy;
ToolkitLogger.LogDebug("DeleteGameObjectCommand", $" GameObject 삭제: {gameObjectName}");
await UniTask.Yield();
return true;
}
catch (Exception ex)
{
ToolkitLogger.LogError("DeleteGameObjectCommand", $" 삭제 실패: {ex.Message}");
return false;
}
}
protected override async UniTask<bool> OnUndoAsync()
{
try
{
if (deletedGameObject == null)
{
ToolkitLogger.LogWarning("DeleteGameObjectCommand", $" 복원할 GameObject 참조가 없음: {gameObjectName}");
return false;
}
// GameObject 복원
deletedGameObject.SetActive(true);
deletedGameObject.hideFlags = HideFlags.None;
// 부모 및 위치 복원
if (parentInstanceId != 0)
{
var parent = UnityEditor.EditorUtility.InstanceIDToObject(parentInstanceId) as GameObject;
if (parent != null)
{
deletedGameObject.transform.SetParent(parent.transform, false);
deletedGameObject.transform.SetSiblingIndex(siblingIndex);
}
}
deletedGameObject.transform.position = position;
deletedGameObject.transform.rotation = rotation;
deletedGameObject.transform.localScale = scale;
ToolkitLogger.LogDebug("DeleteGameObjectCommand", $" GameObject 복원 (Undo): {gameObjectName}");
await UniTask.Yield();
return true;
}
catch (Exception ex)
{
ToolkitLogger.LogError("DeleteGameObjectCommand", $" Undo 실패: {ex.Message}");
return false;
}
}
protected override async UniTask<bool> OnRedoAsync()
{
// Redo는 Execute와 동일
return await OnExecuteAsync();
}
#endregion
#region Persistence Override
public override bool CanPersist => false; // GameObject 참조를 포함하므로 DB 저장 불가
#endregion
#region Serialization
public override string Serialize()
{
return JsonUtility.ToJson(new DeleteGameObjectData
{
commandId = CommandId,
commandName = CommandName,
executedAt = ExecutedAt.ToString("o"),
gameObjectInstanceId = gameObjectInstanceId,
gameObjectName = gameObjectName,
position = position,
rotation = rotation,
scale = scale,
parentInstanceId = parentInstanceId,
siblingIndex = siblingIndex
});
}
[Serializable]
private class DeleteGameObjectData
{
public string commandId;
public string commandName;
public string executedAt;
public int gameObjectInstanceId;
public string gameObjectName;
public Vector3 position;
public Quaternion rotation;
public Vector3 scale;
public int parentInstanceId;
public int siblingIndex;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 96e777a7e70d2f245be762670a226d75

View File

@@ -0,0 +1,57 @@
using System;
using Cysharp.Threading.Tasks;
namespace UnityEditorToolkit.Editor.Database.Commands
{
/// <summary>
/// Command Pattern 인터페이스
/// 모든 실행 가능한 명령은 이 인터페이스를 구현해야 함
/// </summary>
public interface ICommand
{
/// <summary>
/// 명령 고유 ID (데이터베이스 저장용)
/// </summary>
string CommandId { get; }
/// <summary>
/// 명령 이름 (UI 표시용)
/// </summary>
string CommandName { get; }
/// <summary>
/// 명령 실행 시간
/// </summary>
DateTime ExecutedAt { get; }
/// <summary>
/// 명령 실행
/// </summary>
UniTask<bool> ExecuteAsync();
/// <summary>
/// 명령 실행 취소 (Undo)
/// </summary>
UniTask<bool> UndoAsync();
/// <summary>
/// 명령 재실행 (Redo)
/// </summary>
UniTask<bool> RedoAsync();
/// <summary>
/// 명령을 데이터베이스에 저장할 수 있는지 여부
/// </summary>
bool CanPersist { get; }
/// <summary>
/// 명령을 JSON으로 직렬화
/// </summary>
string Serialize();
/// <summary>
/// JSON에서 명령 복원
/// </summary>
void Deserialize(string json);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54a0273f9b663c14cb5651614952b46f

View File

@@ -0,0 +1,160 @@
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Commands
{
/// <summary>
/// GameObject Transform 변경 명령
/// Position, Rotation, Scale 변경을 추적하고 Undo/Redo 지원
/// </summary>
public class TransformChangeCommand : CommandBase
{
#region Fields
private readonly int gameObjectInstanceId;
private readonly Vector3 oldPosition;
private readonly Quaternion oldRotation;
private readonly Vector3 oldScale;
private readonly Vector3 newPosition;
private readonly Quaternion newRotation;
private readonly Vector3 newScale;
private readonly string gameObjectName;
#endregion
#region Constructor
public TransformChangeCommand(
GameObject gameObject,
Vector3 oldPosition, Quaternion oldRotation, Vector3 oldScale,
Vector3 newPosition, Quaternion newRotation, Vector3 newScale)
: base($"Transform Change: {gameObject.name}")
{
gameObjectInstanceId = gameObject.GetInstanceID();
gameObjectName = gameObject.name;
this.oldPosition = oldPosition;
this.oldRotation = oldRotation;
this.oldScale = oldScale;
this.newPosition = newPosition;
this.newRotation = newRotation;
this.newScale = newScale;
}
#endregion
#region Command Implementation
protected override async UniTask<bool> OnExecuteAsync()
{
var go = GetGameObject();
if (go == null) return false;
// 새로운 Transform 적용
go.transform.position = newPosition;
go.transform.rotation = newRotation;
go.transform.localScale = newScale;
await UniTask.Yield();
return true;
}
protected override async UniTask<bool> OnUndoAsync()
{
var go = GetGameObject();
if (go == null) return false;
// 이전 Transform 복원
go.transform.position = oldPosition;
go.transform.rotation = oldRotation;
go.transform.localScale = oldScale;
await UniTask.Yield();
return true;
}
protected override async UniTask<bool> OnRedoAsync()
{
// Redo는 Execute와 동일
return await OnExecuteAsync();
}
#endregion
#region Helper Methods
private GameObject GetGameObject()
{
var go = UnityEditor.EditorUtility.InstanceIDToObject(gameObjectInstanceId) as GameObject;
if (go == null)
{
ToolkitLogger.LogWarning("TransformChangeCommand", $"GameObject not found: {gameObjectName} (ID: {gameObjectInstanceId})");
}
return go;
}
#endregion
#region Serialization
public override string Serialize()
{
return JsonUtility.ToJson(new TransformChangeData
{
commandId = CommandId,
commandName = CommandName,
executedAt = ExecutedAt.ToString("o"),
gameObjectInstanceId = gameObjectInstanceId,
gameObjectName = gameObjectName,
oldPosition = oldPosition,
oldRotation = oldRotation,
oldScale = oldScale,
newPosition = newPosition,
newRotation = newRotation,
newScale = newScale
});
}
/// <summary>
/// JSON에서 Command 복원 (세션 영속성용)
/// </summary>
public static TransformChangeCommand FromJson(string json)
{
var data = JsonUtility.FromJson<TransformChangeData>(json);
// GameObject 찾기
var go = UnityEditor.EditorUtility.InstanceIDToObject(data.gameObjectInstanceId) as GameObject;
if (go == null)
{
ToolkitLogger.LogWarning("TransformChangeCommand", $"GameObject not found (FromJson): {data.gameObjectName} (ID: {data.gameObjectInstanceId})");
// GameObject가 없어도 Command는 생성 (히스토리 기록용)
go = new GameObject(data.gameObjectName);
}
// 새 인스턴스 생성
var command = new TransformChangeCommand(
go,
data.oldPosition, data.oldRotation, data.oldScale,
data.newPosition, data.newRotation, data.newScale
);
// 메타데이터 복원
command.CommandId = data.commandId;
command.CommandName = data.commandName;
command.ExecutedAt = DateTime.Parse(data.executedAt);
return command;
}
[Serializable]
private class TransformChangeData
{
public string commandId;
public string commandName;
public string executedAt;
public int gameObjectInstanceId;
public string gameObjectName;
public Vector3 oldPosition;
public Quaternion oldRotation;
public Vector3 oldScale;
public Vector3 newPosition;
public Quaternion newRotation;
public Vector3 newScale;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8a0112613fb1ea24bb6632ee8960a2f2

View File

@@ -0,0 +1,347 @@
using System;
using System.IO;
using UnityEngine;
using UnityEditor;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database
{
/// <summary>
/// SQLite 데이터베이스 연결 설정
/// 임베디드 SQLite - 설치 불필요, 단일 파일 DB
/// EditorPrefs에 개별 키로 저장/로드
/// </summary>
[Serializable]
public class DatabaseConfig
{
#region EditorPrefs Keys
/// <summary>
/// EditorPrefs 키 상수
/// </summary>
private const string PREF_KEY_CONFIG_VERSION = "UnityEditorToolkit.Database.ConfigVersion";
private const string PREF_KEY_ENABLE_DATABASE = "UnityEditorToolkit.Database.EnableDatabase";
private const string PREF_KEY_FILE_PATH = "UnityEditorToolkit.Database.FilePath";
private const string PREF_KEY_ENABLE_WAL = "UnityEditorToolkit.Database.EnableWAL";
private const string PREF_KEY_ENABLE_ENCRYPTION = "UnityEditorToolkit.Database.EnableEncryption";
private const string PREF_KEY_ENCRYPTION_KEY = "UnityEditorToolkit.Database.EncryptionKey";
/// <summary>
/// 현재 설정 버전
/// </summary>
public const int CURRENT_VERSION = 1;
#endregion
#region Private Fields
/// <summary>
/// 설정 버전 (마이그레이션용)
/// v1: EnableWAL 기본값 true 변경, EnableDatabase 기본값 true 변경
/// </summary>
[SerializeField]
private int configVersion = 0;
/// <summary>
/// 데이터베이스 기능 활성화 여부
/// </summary>
[SerializeField]
private bool enableDatabase = true;
/// <summary>
/// SQLite 데이터베이스 파일 경로
/// 기본값: Application.persistentDataPath + "/unity_editor_toolkit.db"
/// </summary>
[SerializeField]
private string databaseFilePath = "";
/// <summary>
/// WAL (Write-Ahead Logging) 모드 사용 여부
/// 성능 향상 및 동시성 개선
/// </summary>
[SerializeField]
private bool enableWAL = true;
/// <summary>
/// 암호화 사용 여부 (SQLite Multiple Ciphers)
/// </summary>
[SerializeField]
private bool enableEncryption = false;
/// <summary>
/// 암호화 키 (암호화 사용 시 필요)
/// 주의: 평문 저장, 프로덕션에서는 보안 저장소 사용 권장
/// </summary>
[SerializeField]
private string encryptionKey = "";
#endregion
#region Public Properties
public int ConfigVersion
{
get => configVersion;
set => configVersion = value;
}
public bool EnableDatabase
{
get => enableDatabase;
set => enableDatabase = value;
}
public string DatabaseFilePath
{
get
{
// 빈 문자열이면 기본 경로 반환
if (string.IsNullOrEmpty(databaseFilePath))
{
return GetDefaultDatabasePath();
}
return databaseFilePath;
}
set => databaseFilePath = value;
}
public bool EnableWAL
{
get => enableWAL;
set => enableWAL = value;
}
public bool EnableEncryption
{
get => enableEncryption;
set => enableEncryption = value;
}
public string EncryptionKey
{
get => encryptionKey;
set => encryptionKey = value;
}
#endregion
#region Connection String
/// <summary>
/// SQLite 연결 문자열 생성
/// </summary>
/// <returns>SQLite 연결 문자열</returns>
public string GetConnectionString()
{
string dbPath = DatabaseFilePath;
// 기본 연결 문자열
string connectionString = $"Data Source={dbPath}";
// 암호화 사용 시
if (enableEncryption && !string.IsNullOrEmpty(encryptionKey))
{
connectionString += $";Password={encryptionKey}";
}
return connectionString;
}
#endregion
#region Static Methods
/// <summary>
/// 기본 데이터베이스 파일 경로 가져오기
/// </summary>
/// <returns>기본 경로</returns>
public static string GetDefaultDatabasePath()
{
// Unity persistentDataPath 사용 (플랫폼별 자동 선택)
// Windows: %USERPROFILE%\AppData\LocalLow\CompanyName\ProductName
// macOS: ~/Library/Application Support/CompanyName/ProductName
// Linux: ~/.config/unity3d/CompanyName/ProductName
return Path.Combine(Application.persistentDataPath, "unity_editor_toolkit.db");
}
#endregion
#region Validation
/// <summary>
/// 설정 유효성 검증
/// </summary>
/// <returns>유효성 검증 결과</returns>
public ValidationResult Validate()
{
// 데이터베이스 비활성화 시 검증 통과
if (!enableDatabase)
{
return new ValidationResult { IsValid = true };
}
// 파일 경로 검증
string dbPath = DatabaseFilePath;
if (string.IsNullOrWhiteSpace(dbPath))
{
return new ValidationResult
{
IsValid = false,
ErrorMessage = "Database file path는 필수 항목입니다."
};
}
// 디렉토리 존재 여부 확인 (없으면 생성 가능하도록 경고만)
string directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
return new ValidationResult
{
IsValid = false,
ErrorMessage = $"디렉토리 생성 실패: {ex.Message}"
};
}
}
// 암호화 사용 시 키 검증
if (enableEncryption && string.IsNullOrWhiteSpace(encryptionKey))
{
return new ValidationResult
{
IsValid = false,
ErrorMessage = "암호화 사용 시 Encryption Key는 필수 항목입니다."
};
}
return new ValidationResult { IsValid = true };
}
#endregion
#region Reset
/// <summary>
/// 기본값으로 초기화
/// </summary>
public void Reset()
{
configVersion = CURRENT_VERSION;
enableDatabase = true; // SQLite는 설치 불필요, 기본 활성화
databaseFilePath = "";
enableWAL = true;
enableEncryption = false;
encryptionKey = "";
}
#endregion
#region EditorPrefs Save/Load
/// <summary>
/// EditorPrefs에 개별 키로 저장
/// </summary>
public void SaveToEditorPrefs()
{
EditorPrefs.SetInt(PREF_KEY_CONFIG_VERSION, configVersion);
EditorPrefs.SetBool(PREF_KEY_ENABLE_DATABASE, enableDatabase);
EditorPrefs.SetString(PREF_KEY_FILE_PATH, databaseFilePath);
EditorPrefs.SetBool(PREF_KEY_ENABLE_WAL, enableWAL);
EditorPrefs.SetBool(PREF_KEY_ENABLE_ENCRYPTION, enableEncryption);
EditorPrefs.SetString(PREF_KEY_ENCRYPTION_KEY, encryptionKey);
}
/// <summary>
/// EditorPrefs에서 개별 키로 로드 (마이그레이션 포함)
/// </summary>
/// <returns>DatabaseConfig 인스턴스</returns>
public static DatabaseConfig LoadFromEditorPrefs()
{
var config = new DatabaseConfig();
// ConfigVersion 확인 (마이그레이션 판단용)
int savedVersion = EditorPrefs.GetInt(PREF_KEY_CONFIG_VERSION, -1);
if (savedVersion == -1)
{
// 첫 실행: 기본값 사용 (저장하지 않음)
ToolkitLogger.Log("DatabaseConfig", "첫 실행 감지 - 기본값 사용");
config.Reset();
return config;
}
// 개별 키에서 로드
config.configVersion = savedVersion;
config.enableDatabase = EditorPrefs.GetBool(PREF_KEY_ENABLE_DATABASE, true);
config.databaseFilePath = EditorPrefs.GetString(PREF_KEY_FILE_PATH, "");
config.enableWAL = EditorPrefs.GetBool(PREF_KEY_ENABLE_WAL, true);
config.enableEncryption = EditorPrefs.GetBool(PREF_KEY_ENABLE_ENCRYPTION, false);
config.encryptionKey = EditorPrefs.GetString(PREF_KEY_ENCRYPTION_KEY, "");
// 마이그레이션 필요 여부 확인
if (config.configVersion < CURRENT_VERSION)
{
ToolkitLogger.Log("DatabaseConfig", $"마이그레이션: v{config.configVersion} → v{CURRENT_VERSION}");
MigrateConfig(config);
config.SaveToEditorPrefs(); // 마이그레이션 후 저장
}
return config;
}
/// <summary>
/// EditorPrefs 키 모두 삭제
/// </summary>
public static void ClearEditorPrefs()
{
EditorPrefs.DeleteKey(PREF_KEY_CONFIG_VERSION);
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_DATABASE);
EditorPrefs.DeleteKey(PREF_KEY_FILE_PATH);
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_WAL);
EditorPrefs.DeleteKey(PREF_KEY_ENABLE_ENCRYPTION);
EditorPrefs.DeleteKey(PREF_KEY_ENCRYPTION_KEY);
ToolkitLogger.Log("DatabaseConfig", "EditorPrefs 초기화 완료");
}
/// <summary>
/// 설정 마이그레이션 (이전 버전 → 최신 버전)
/// </summary>
/// <param name="config">마이그레이션할 설정</param>
private static void MigrateConfig(DatabaseConfig config)
{
// v0 → v1: EnableWAL과 EnableDatabase 기본값을 true로 변경
if (config.configVersion < 1)
{
bool changed = false;
if (!config.enableWAL)
{
ToolkitLogger.Log("DatabaseConfig", "마이그레이션: EnableWAL을 true로 변경 (v0 → v1)");
config.enableWAL = true;
changed = true;
}
if (!config.enableDatabase)
{
ToolkitLogger.Log("DatabaseConfig", "마이그레이션: EnableDatabase를 true로 변경 (v0 → v1)");
config.enableDatabase = true;
changed = true;
}
if (changed)
{
ToolkitLogger.Log("DatabaseConfig", "SQLite는 설치가 필요없으므로 Database 기능이 기본 활성화됩니다.");
}
config.configVersion = 1;
}
}
#endregion
}
/// <summary>
/// 설정 유효성 검증 결과
/// </summary>
public struct ValidationResult
{
/// <summary>
/// 유효성 검증 통과 여부
/// </summary>
public bool IsValid;
/// <summary>
/// 검증 실패 시 에러 메시지
/// </summary>
public string ErrorMessage;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f131f462b224eb479a0d27ce5c885b5

View File

@@ -0,0 +1,598 @@
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditor;
using UnityEditor.Compilation;
using UnityEditorToolkit.Editor.Database.Commands;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database
{
/// <summary>
/// SQLite 데이터베이스 관리 싱글톤
/// 임베디드 SQLite - 설치 불필요, 단일 파일 DB
/// Domain Reload 시 자동으로 연결 정리 및 재연결
/// </summary>
[InitializeOnLoad]
public class DatabaseManager
{
#region Domain Reload Handling
private const string PREF_KEY_DB_WAS_CONNECTED = "UnityEditorToolkit.Database.WasConnected";
private const string PREF_KEY_DB_PATH = "UnityEditorToolkit.Database.Path";
private const string PREF_KEY_DB_ENABLE_WAL = "UnityEditorToolkit.Database.EnableWAL";
static DatabaseManager()
{
// Domain Reload 전: 연결 정리
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
// Domain Reload 후: 자동 재연결
EditorApplication.delayCall += OnAfterAssemblyReload;
}
private static void OnBeforeAssemblyReload()
{
if (instance != null && instance.IsConnected)
{
ToolkitLogger.Log("DatabaseManager", "Domain Reload 감지 - 연결 상태 저장 및 정리 중...");
// 연결 상태 저장
EditorPrefs.SetBool(PREF_KEY_DB_WAS_CONNECTED, true);
if (instance.config != null)
{
EditorPrefs.SetString(PREF_KEY_DB_PATH, instance.config.DatabaseFilePath);
EditorPrefs.SetBool(PREF_KEY_DB_ENABLE_WAL, instance.config.EnableWAL);
}
// 연결 정리 (동기 방식)
try
{
instance.connector?.DisconnectAsync().Forget();
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseManager", $" Shutdown 중 예외: {ex.Message}");
}
}
}
private static void OnAfterAssemblyReload()
{
// 이전에 연결되어 있었는지 확인
bool wasConnected = EditorPrefs.GetBool(PREF_KEY_DB_WAS_CONNECTED, false);
if (wasConnected)
{
ToolkitLogger.Log("DatabaseManager", "Domain Reload 완료 - 자동 재연결 시도...");
// 연결 상태 플래그 클리어
EditorPrefs.DeleteKey(PREF_KEY_DB_WAS_CONNECTED);
// 설정 복원 및 재연결
string dbPath = EditorPrefs.GetString(PREF_KEY_DB_PATH, "");
bool enableWAL = EditorPrefs.GetBool(PREF_KEY_DB_ENABLE_WAL, true);
if (!string.IsNullOrEmpty(dbPath))
{
var config = new DatabaseConfig
{
DatabaseFilePath = dbPath,
EnableWAL = enableWAL
};
// 비동기 재연결
Instance.InitializeAsync(config).Forget();
ToolkitLogger.Log("DatabaseManager", "자동 재연결 완료.");
}
}
}
#endregion
#region Singleton
private static DatabaseManager instance;
private static readonly object @lock = new object();
public static DatabaseManager Instance
{
get
{
if (instance == null)
{
lock (@lock)
{
if (instance == null)
{
instance = new DatabaseManager();
}
}
}
return instance;
}
}
private DatabaseManager()
{
// Private constructor for singleton
}
#endregion
#region Fields
private DatabaseConfig config;
private SQLiteConnector connector;
private CommandHistory commandHistory;
private SyncManager syncManager;
private bool isInitialized = false;
private bool isConnected = false;
private CancellationTokenSource lifecycleCts;
private static bool isInitializing = false; // Race condition prevention
private static UniTaskCompletionSource<InitializationResult> initializationTcs; // Wait-based: 초기화 결과 공유
private bool isMigrationRunning = false; // Migration in progress flag
#endregion
#region Properties
/// <summary>
/// 데이터베이스 초기화 완료 여부
/// </summary>
public bool IsInitialized => isInitialized;
/// <summary>
/// 데이터베이스 연결 상태
/// </summary>
public bool IsConnected => isConnected && connector != null && connector.IsConnected;
/// <summary>
/// 현재 데이터베이스 설정
/// </summary>
public DatabaseConfig Config => config;
/// <summary>
/// SQLite 커넥터
/// </summary>
public SQLiteConnector Connector => connector;
/// <summary>
/// Command History (Undo/Redo)
/// </summary>
public CommandHistory CommandHistory => commandHistory;
/// <summary>
/// Sync Manager (실시간 동기화)
/// </summary>
public SyncManager SyncManager => syncManager;
/// <summary>
/// 마이그레이션 실행 중 여부
/// </summary>
public bool IsMigrationRunning => isMigrationRunning;
#endregion
#region Initialization
/// <summary>
/// 데이터베이스 초기화
/// </summary>
/// <param name="config">데이터베이스 설정</param>
public async UniTask<InitializationResult> InitializeAsync(DatabaseConfig config)
{
// Wait-based: 이미 진행 중인 초기화가 있으면 완료 대기
UniTaskCompletionSource<InitializationResult> existingTcs = null;
// Race condition prevention: 이미 초기화 진행 중인 경우
lock (@lock)
{
if (isInitializing)
{
// 진행 중인 초기화가 있으면 TCS 참조 저장 (lock 밖에서 대기)
existingTcs = initializationTcs;
}
else if (isInitialized)
{
// 이미 초기화된 경우
ToolkitLogger.LogWarning("DatabaseManager", "이미 초기화되었습니다. Shutdown 후 재초기화하세요.");
return new InitializationResult
{
Success = false,
ErrorMessage = "Already initialized. Call Shutdown() first."
};
}
else
{
// 새로운 초기화 시작
isInitializing = true;
initializationTcs = new UniTaskCompletionSource<InitializationResult>();
}
}
// 진행 중인 초기화가 있으면 완료될 때까지 대기 후 결과 공유
if (existingTcs != null)
{
ToolkitLogger.Log("DatabaseManager", "다른 초기화가 진행 중입니다. 완료를 대기합니다...");
return await existingTcs.Task;
}
// 새로운 초기화 시작
InitializationResult result = default;
try
{
// 데이터베이스 비활성화 시
if (!config.EnableDatabase)
{
ToolkitLogger.Log("DatabaseManager", "데이터베이스 기능이 비활성화되어 있습니다.");
result = new InitializationResult
{
Success = true,
Message = "Database feature is disabled."
};
return result;
}
// 설정 유효성 검증
var validation = config.Validate();
if (!validation.IsValid)
{
ToolkitLogger.LogError("DatabaseManager", $" 설정 유효성 검증 실패: {validation.ErrorMessage}");
result = new InitializationResult
{
Success = false,
ErrorMessage = validation.ErrorMessage
};
return result;
}
// 설정 저장
this.config = config;
// CancellationTokenSource 생성
lifecycleCts = new CancellationTokenSource();
// SQLite 커넥터 생성
connector = new SQLiteConnector(this.config);
// Command History 생성
commandHistory = new CommandHistory(this);
// 연결 테스트
var connectResult = await connector.ConnectAsync(lifecycleCts.Token);
if (!connectResult.Success)
{
ToolkitLogger.LogError("DatabaseManager", $" 연결 실패: {connectResult.ErrorMessage}");
await CleanupAsync();
result = new InitializationResult
{
Success = false,
ErrorMessage = connectResult.ErrorMessage
};
return result;
}
isConnected = true;
isInitialized = true;
// 자동 마이그레이션 실행
await RunAutoMigrationAsync(lifecycleCts.Token);
// SyncManager 초기화
syncManager = new SyncManager(this);
ToolkitLogger.Log("DatabaseManager", "SyncManager initialized.");
ToolkitLogger.Log("DatabaseManager", $" 초기화 완료: {this.config.DatabaseFilePath}");
result = new InitializationResult
{
Success = true,
Message = "Initialization successful."
};
return result;
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseManager", $" 초기화 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
await CleanupAsync();
result = new InitializationResult
{
Success = false,
ErrorMessage = ex.Message
};
return result;
}
finally
{
// TCS에 결과 설정하여 대기 중인 호출자들에게 결과 전달
lock (@lock)
{
isInitializing = false;
var tcs = initializationTcs;
initializationTcs = null;
tcs?.TrySetResult(result);
}
}
}
/// <summary>
/// 데이터베이스 종료 및 리소스 정리
/// </summary>
public async UniTask ShutdownAsync()
{
if (!isInitialized)
{
return;
}
ToolkitLogger.Log("DatabaseManager", "Shutting down...");
try
{
// CancellationToken 취소
lifecycleCts?.Cancel();
// 리소스 정리
await CleanupAsync();
isInitialized = false;
isConnected = false;
ToolkitLogger.Log("DatabaseManager", "Shutdown 완료.");
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseManager", $" Shutdown 중 예외 발생: {ex.Message}");
}
}
/// <summary>
/// 내부 리소스 정리
/// </summary>
private async UniTask CleanupAsync()
{
try
{
// SyncManager 정리
if (syncManager != null)
{
syncManager.Dispose();
syncManager = null;
ToolkitLogger.Log("DatabaseManager", "SyncManager disposed.");
}
// Command History 정리
if (commandHistory != null)
{
commandHistory.Clear();
commandHistory = null;
}
// 커넥터 정리
if (connector != null)
{
await connector.DisconnectAsync();
connector = null;
}
// CancellationTokenSource 정리
lifecycleCts?.Dispose();
lifecycleCts = null;
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseManager", $" Cleanup 중 예외 발생: {ex.Message}");
}
}
#endregion
#region Connection Management
/// <summary>
/// 데이터베이스 연결 해제 (동기 방식 - 서버 종료/Assembly Reload 시 사용)
/// </summary>
public void Disconnect()
{
if (!isInitialized || !isConnected)
{
return;
}
ToolkitLogger.Log("DatabaseManager", "서버 종료로 인한 연결 해제...");
try
{
// CancellationToken 취소
lifecycleCts?.Cancel();
// SyncManager 정리
if (syncManager != null)
{
syncManager.Dispose();
syncManager = null;
}
// Command History 정리
if (commandHistory != null)
{
commandHistory.Clear();
commandHistory = null;
}
// 커넥터 정리 (동기 방식)
connector?.Disconnect();
connector = null;
// CancellationTokenSource 정리
lifecycleCts?.Dispose();
lifecycleCts = null;
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseManager", $" Disconnect 중 예외: {ex.Message}");
}
finally
{
// 상태 업데이트
isConnected = false;
isInitialized = false;
}
ToolkitLogger.Log("DatabaseManager", "연결 해제 완료.");
}
/// <summary>
/// 연결 상태 확인
/// </summary>
public async UniTask<bool> TestConnectionAsync()
{
if (!isInitialized || connector == null)
{
return false;
}
try
{
return await connector.TestConnectionAsync(lifecycleCts?.Token ?? default);
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseManager", $" 연결 테스트 실패: {ex.Message}");
return false;
}
}
/// <summary>
/// 연결 재시도
/// </summary>
public async UniTask<bool> ReconnectAsync()
{
if (!isInitialized || config == null)
{
ToolkitLogger.LogWarning("DatabaseManager", "초기화되지 않았습니다.");
return false;
}
try
{
ToolkitLogger.Log("DatabaseManager", "재연결 시도 중...");
// 기존 연결 종료
if (connector != null)
{
await connector.DisconnectAsync();
}
// 새 커넥터 생성
connector = new SQLiteConnector(config);
// 연결
var result = await connector.ConnectAsync(lifecycleCts?.Token ?? default);
isConnected = result.Success;
if (isConnected)
{
ToolkitLogger.Log("DatabaseManager", "재연결 성공.");
}
else
{
ToolkitLogger.LogError("DatabaseManager", $" 재연결 실패: {result.ErrorMessage}");
}
return isConnected;
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseManager", $" 재연결 중 예외 발생: {ex.Message}");
isConnected = false;
return false;
}
}
#endregion
#region Auto Migration
/// <summary>
/// 자동 마이그레이션 실행 (연결 시 자동 호출)
/// </summary>
private async UniTask RunAutoMigrationAsync(CancellationToken cancellationToken)
{
isMigrationRunning = true;
try
{
ToolkitLogger.Log("DatabaseManager", "자동 마이그레이션 확인 중...");
var migrationRunner = new MigrationRunner(this);
var result = await migrationRunner.RunMigrationsAsync(cancellationToken);
if (result.Success)
{
if (result.MigrationsApplied > 0)
{
ToolkitLogger.Log("DatabaseManager", $" 자동 마이그레이션 완료: {result.MigrationsApplied}개 적용됨");
}
else
{
ToolkitLogger.Log("DatabaseManager", "마이그레이션이 최신 상태입니다.");
}
}
else
{
ToolkitLogger.LogWarning("DatabaseManager", $" 자동 마이그레이션 실패: {result.ErrorMessage}");
}
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("DatabaseManager", $" 자동 마이그레이션 중 예외 발생: {ex.Message}");
// 마이그레이션 실패해도 연결은 유지
}
finally
{
isMigrationRunning = false;
}
}
#endregion
#region Health Check
/// <summary>
/// 데이터베이스 상태 정보 조회
/// </summary>
public DatabaseHealthStatus GetHealthStatus()
{
return new DatabaseHealthStatus
{
IsInitialized = isInitialized,
IsConnected = IsConnected,
IsEnabled = config?.EnableDatabase ?? false,
DatabaseFilePath = config?.DatabaseFilePath ?? "N/A",
DatabaseFileExists = connector?.DatabaseFileExists() ?? false
};
}
#endregion
}
#region Result Structs
/// <summary>
/// 초기화 결과
/// </summary>
public struct InitializationResult
{
public bool Success;
public string Message;
public string ErrorMessage;
}
/// <summary>
/// 데이터베이스 상태
/// </summary>
public struct DatabaseHealthStatus
{
public bool IsInitialized;
public bool IsConnected;
public bool IsEnabled;
public string DatabaseFilePath;
public bool DatabaseFileExists;
public override string ToString()
{
return $"[DatabaseHealthStatus]\n" +
$" Initialized: {IsInitialized}\n" +
$" Connected: {IsConnected}\n" +
$" Enabled: {IsEnabled}\n" +
$" Database File: {DatabaseFilePath}\n" +
$" File Exists: {DatabaseFileExists}";
}
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c63a562fc80ebc42a10afeb117fa4a2

View File

@@ -0,0 +1,596 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database
{
/// <summary>
/// 데이터베이스 마이그레이션 자동 실행
/// SQL 파일을 순서대로 실행하여 스키마 버전 관리
/// SQLite 버전 - 트랜잭션 지원
/// </summary>
public class MigrationRunner
{
#region Fields
private readonly DatabaseManager databaseManager;
private readonly string migrationsPath;
#endregion
#region Constructor
public MigrationRunner(DatabaseManager databaseManager, string migrationsPath = null)
{
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
// 마이그레이션 폴더 경로 (기본값: Editor/Database/Migrations)
if (string.IsNullOrEmpty(migrationsPath))
{
// Unity 패키지 내 Migrations 폴더 경로
this.migrationsPath = Path.Combine(Application.dataPath, "..", "Packages",
"com.devgom.unity-editor-toolkit", "Editor", "Database", "Migrations");
}
else
{
this.migrationsPath = migrationsPath;
}
ToolkitLogger.LogDebug("MigrationRunner", $" 생성 완료. Migrations Path: {this.migrationsPath}");
}
#endregion
#region Migration Execution
/// <summary>
/// 모든 마이그레이션 실행 (순서대로)
/// </summary>
public async UniTask<MigrationResult> RunMigrationsAsync(CancellationToken cancellationToken = default)
{
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
{
return new MigrationResult
{
Success = false,
ErrorMessage = "DatabaseManager not initialized or not connected."
};
}
try
{
ToolkitLogger.LogDebug("MigrationRunner", "마이그레이션 시작...");
// 1. migrations 테이블 생성 (존재하지 않으면)
await EnsureMigrationTableExistsAsync(cancellationToken);
// 2. 실행된 마이그레이션 목록 조회
var appliedMigrations = await GetAppliedMigrationsAsync(cancellationToken);
ToolkitLogger.LogDebug("MigrationRunner", $" 이미 실행된 마이그레이션: {appliedMigrations.Count}개");
// 3. 마이그레이션 파일 목록 조회
var migrationFiles = GetMigrationFiles();
if (migrationFiles.Count == 0)
{
ToolkitLogger.LogWarning("MigrationRunner", $" 마이그레이션 파일이 없습니다: {migrationsPath}");
return new MigrationResult
{
Success = true,
Message = "No migration files found.",
MigrationsApplied = 0
};
}
ToolkitLogger.LogDebug("MigrationRunner", $" 발견된 마이그레이션 파일: {migrationFiles.Count}개");
// 4. 미실행 마이그레이션 필터링
var pendingMigrations = migrationFiles
.Where(file => !appliedMigrations.Contains(Path.GetFileNameWithoutExtension(file)))
.OrderBy(file => file)
.ToList();
if (pendingMigrations.Count == 0)
{
ToolkitLogger.LogDebug("MigrationRunner", "실행할 마이그레이션이 없습니다.");
return new MigrationResult
{
Success = true,
Message = "All migrations already applied.",
MigrationsApplied = 0
};
}
ToolkitLogger.LogDebug("MigrationRunner", $" 실행할 마이그레이션: {pendingMigrations.Count}개");
// 5. 마이그레이션 실행
int appliedCount = 0;
foreach (var migrationFile in pendingMigrations)
{
string migrationName = Path.GetFileNameWithoutExtension(migrationFile);
ToolkitLogger.LogDebug("MigrationRunner", $" 실행 중: {migrationName}");
var result = await ApplyMigrationAsync(migrationFile, cancellationToken);
if (!result.Success)
{
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 실패: {migrationName}\n{result.ErrorMessage}");
return new MigrationResult
{
Success = false,
ErrorMessage = $"Failed to apply migration: {migrationName}\n{result.ErrorMessage}",
MigrationsApplied = appliedCount
};
}
appliedCount++;
ToolkitLogger.LogDebug("MigrationRunner", $" 완료: {migrationName}");
}
ToolkitLogger.LogDebug("MigrationRunner", $" 마이그레이션 완료: {appliedCount}개 적용됨");
return new MigrationResult
{
Success = true,
Message = $"Successfully applied {appliedCount} migration(s).",
MigrationsApplied = appliedCount
};
}
catch (Exception ex)
{
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
return new MigrationResult
{
Success = false,
ErrorMessage = ex.Message,
MigrationsApplied = 0
};
}
}
/// <summary>
/// 단일 마이그레이션 실행
/// </summary>
private async UniTask<MigrationResult> ApplyMigrationAsync(string filePath, CancellationToken cancellationToken)
{
try
{
// SQL 파일 읽기
string sql = File.ReadAllText(filePath);
if (string.IsNullOrWhiteSpace(sql))
{
return new MigrationResult
{
Success = false,
ErrorMessage = "Migration file is empty."
};
}
string migrationName = Path.GetFileNameWithoutExtension(filePath);
await UniTask.RunOnThreadPool(() =>
{
var connection = databaseManager.Connector.Connection;
// 트랜잭션으로 마이그레이션 원자성 보장
// 마이그레이션 중 오류 발생 시 롤백으로 데이터베이스 일관성 유지
connection.BeginTransaction();
try
{
// SQL을 완전한 문장 단위로 분리 (BEGIN...END 블록 고려)
var sqlStatements = SplitSqlStatements(sql);
int executedCount = 0;
foreach (var statement in sqlStatements)
{
var trimmedStatement = statement.Trim();
if (string.IsNullOrWhiteSpace(trimmedStatement))
continue;
// 주석만 있는 문장 스킵 (Execute()에 전달하면 "not an error" 발생)
string withoutComments = RemoveSqlComments(trimmedStatement);
if (string.IsNullOrWhiteSpace(withoutComments))
{
ToolkitLogger.LogDebug("MigrationRunner", $" 주석만 있는 문장 스킵 (RemoveSqlComments v2 적용됨)");
continue;
}
// 주석이 제거된 SQL 사용 (Execute()가 주석을 처리하지 못할 수 있음)
string cleanedSql = withoutComments.Trim();
// SELECT 문 (결과 메시지용)은 스킵 - Execute()는 결과를 반환하지 않음
if (cleanedSql.StartsWith("SELECT ", StringComparison.OrdinalIgnoreCase))
{
ToolkitLogger.LogDebug("MigrationRunner", $" SELECT 문 스킵");
continue;
}
// PRAGMA 문은 스킵 (SQLiteConnector에서 이미 설정됨)
if (cleanedSql.StartsWith("PRAGMA ", StringComparison.OrdinalIgnoreCase))
{
ToolkitLogger.LogDebug("MigrationRunner", $" PRAGMA 문 스킵: {cleanedSql}");
continue;
}
// SQL 실행 (오류 발생 시 어떤 문에서 발생했는지 확인용)
try
{
connection.Execute(cleanedSql);
executedCount++;
}
catch (Exception sqlEx)
{
// SQL 문의 첫 100자만 출력 (너무 길면 로그가 지저분해짐)
string sqlPreview = trimmedStatement.Length > 100
? trimmedStatement.Substring(0, 100) + "..."
: trimmedStatement;
ToolkitLogger.LogError("MigrationRunner", $" SQL 실행 실패 ({executedCount + 1}번째): {sqlEx.Message}\nSQL: {sqlPreview}");
throw;
}
}
ToolkitLogger.LogDebug("MigrationRunner", $" SQL 문장 실행 완료: {executedCount}개");
// migrations 테이블에 기록
string insertSql = @"
INSERT INTO migrations (migration_name, applied_at)
VALUES (?, datetime('now'));";
connection.Execute(insertSql, migrationName);
// 트랜잭션 커밋
connection.Commit();
ToolkitLogger.LogDebug("MigrationRunner", $" 마이그레이션 트랜잭션 커밋 완료: {migrationName}");
}
catch (Exception ex)
{
// 트랜잭션 롤백
connection.Rollback();
ToolkitLogger.LogError("MigrationRunner", $" 마이그레이션 실패 - 롤백됨: {ex.Message}");
throw;
}
}, cancellationToken: cancellationToken);
return new MigrationResult { Success = true };
}
catch (Exception ex)
{
return new MigrationResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
#endregion
#region Migration Table Management
/// <summary>
/// migrations 테이블 생성 (존재하지 않으면)
/// </summary>
private async UniTask EnsureMigrationTableExistsAsync(CancellationToken cancellationToken)
{
string createTableSql = @"
CREATE TABLE IF NOT EXISTS migrations (
migration_id INTEGER PRIMARY KEY AUTOINCREMENT,
migration_name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_migrations_name ON migrations(migration_name);
";
await UniTask.RunOnThreadPool(() =>
{
var connection = databaseManager.Connector.Connection;
connection.Execute(createTableSql);
}, cancellationToken: cancellationToken);
ToolkitLogger.LogDebug("MigrationRunner", "migrations 테이블 확인 완료.");
}
/// <summary>
/// 실행된 마이그레이션 목록 조회
/// </summary>
private async UniTask<List<string>> GetAppliedMigrationsAsync(CancellationToken cancellationToken)
{
var appliedMigrations = new List<string>();
string selectSql = "SELECT migration_name FROM migrations ORDER BY migration_id ASC;";
await UniTask.RunOnThreadPool(() =>
{
var connection = databaseManager.Connector.Connection;
var results = connection.Query<MigrationRecord>(selectSql);
foreach (var record in results)
{
appliedMigrations.Add(record.migration_name);
}
}, cancellationToken: cancellationToken);
return appliedMigrations;
}
/// <summary>
/// Migration 레코드 (SQLite 쿼리 결과용)
/// </summary>
private class MigrationRecord
{
public string migration_name { get; set; }
}
#endregion
#region Pending Migration Check
/// <summary>
/// 대기 중인 마이그레이션 수 조회 (UI 표시용)
/// </summary>
public async UniTask<int> GetPendingMigrationCountAsync(CancellationToken cancellationToken = default)
{
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
{
return -1; // 연결 안됨
}
try
{
// migrations 테이블 확인
await EnsureMigrationTableExistsAsync(cancellationToken);
// 실행된 마이그레이션 목록 조회
var appliedMigrations = await GetAppliedMigrationsAsync(cancellationToken);
// 마이그레이션 파일 목록 조회
var migrationFiles = GetMigrationFiles();
// 미실행 마이그레이션 필터링
var pendingMigrations = migrationFiles
.Where(file => !appliedMigrations.Contains(Path.GetFileNameWithoutExtension(file)))
.ToList();
return pendingMigrations.Count;
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("MigrationRunner", $" 펜딩 마이그레이션 확인 실패: {ex.Message}");
return -1;
}
}
#endregion
#region File Discovery
/// <summary>
/// 마이그레이션 파일 목록 조회 (.sql 파일)
/// </summary>
private List<string> GetMigrationFiles()
{
if (!Directory.Exists(migrationsPath))
{
ToolkitLogger.LogWarning("MigrationRunner", $" Migrations 폴더가 존재하지 않습니다: {migrationsPath}");
return new List<string>();
}
var files = Directory.GetFiles(migrationsPath, "*.sql", SearchOption.TopDirectoryOnly)
.OrderBy(file => file)
.ToList();
return files;
}
/// <summary>
/// SQL 문에서 주석을 제거 (Execute() 호출 전 유효성 검증용)
/// </summary>
private string RemoveSqlComments(string sql)
{
var result = new System.Text.StringBuilder();
bool inSingleLineComment = false;
bool inMultiLineComment = false;
bool inString = false;
for (int i = 0; i < sql.Length; i++)
{
char c = sql[i];
char nextChar = i + 1 < sql.Length ? sql[i + 1] : '\0';
// 줄바꿈 처리 (단일 줄 주석 종료)
if (c == '\n' || c == '\r')
{
inSingleLineComment = false;
if (!inMultiLineComment)
{
result.Append(c);
}
continue;
}
// 단일 줄 주석 시작
if (!inString && !inMultiLineComment && c == '-' && nextChar == '-')
{
inSingleLineComment = true;
i++; // 두 번째 '-' 스킵
continue;
}
// 다중 줄 주석 시작
if (!inString && !inSingleLineComment && c == '/' && nextChar == '*')
{
inMultiLineComment = true;
i++; // '*' 스킵
continue;
}
// 다중 줄 주석 종료
if (inMultiLineComment && c == '*' && nextChar == '/')
{
inMultiLineComment = false;
i++; // '/' 스킵
continue;
}
// 주석 내부면 스킵
if (inSingleLineComment || inMultiLineComment)
{
continue;
}
// 문자열 시작/종료 (작은따옴표)
if (c == '\'')
{
// 이스케이프된 따옴표 확인 ('')
if (inString && nextChar == '\'')
{
result.Append(c);
i++; // 다음 따옴표도 추가
result.Append('\'');
continue;
}
inString = !inString;
}
result.Append(c);
}
return result.ToString();
}
/// <summary>
/// SQL 문장을 BEGIN...END 블록을 고려하여 분리
/// </summary>
private List<string> SplitSqlStatements(string sql)
{
var statements = new List<string>();
var currentStatement = new System.Text.StringBuilder();
int beginEndDepth = 0;
bool inSingleLineComment = false;
bool inMultiLineComment = false;
bool inString = false;
for (int i = 0; i < sql.Length; i++)
{
char c = sql[i];
char nextChar = i + 1 < sql.Length ? sql[i + 1] : '\0';
// 줄바꿈 처리 (단일 줄 주석 종료)
if (c == '\n')
{
inSingleLineComment = false;
currentStatement.Append(c);
continue;
}
// 단일 줄 주석 시작 (문자열 내부가 아닐 때)
if (!inString && !inMultiLineComment && c == '-' && nextChar == '-')
{
inSingleLineComment = true;
currentStatement.Append(c);
continue;
}
// 다중 줄 주석 시작
if (!inString && !inSingleLineComment && c == '/' && nextChar == '*')
{
inMultiLineComment = true;
currentStatement.Append(c);
continue;
}
// 다중 줄 주석 종료
if (inMultiLineComment && c == '*' && nextChar == '/')
{
inMultiLineComment = false;
currentStatement.Append(c);
i++; // '/' 스킵
currentStatement.Append('/');
continue;
}
// 주석 내부면 그대로 추가
if (inSingleLineComment || inMultiLineComment)
{
currentStatement.Append(c);
continue;
}
// 문자열 시작/종료 (작은따옴표)
if (c == '\'')
{
// 이스케이프된 따옴표 확인 ('')
if (inString && nextChar == '\'')
{
currentStatement.Append(c);
i++; // 다음 따옴표도 추가
currentStatement.Append('\'');
continue;
}
inString = !inString;
currentStatement.Append(c);
continue;
}
// 문자열 내부면 그대로 추가
if (inString)
{
currentStatement.Append(c);
continue;
}
// BEGIN 키워드 감지 (대소문자 무시)
if (i + 5 <= sql.Length)
{
string word = sql.Substring(i, 5).ToUpper();
if (word == "BEGIN" && (i == 0 || !char.IsLetterOrDigit(sql[i - 1])) &&
(i + 5 >= sql.Length || !char.IsLetterOrDigit(sql[i + 5])))
{
beginEndDepth++;
}
}
// END 키워드 감지
if (i + 3 <= sql.Length)
{
string word = sql.Substring(i, 3).ToUpper();
if (word == "END" && (i == 0 || !char.IsLetterOrDigit(sql[i - 1])) &&
(i + 3 >= sql.Length || !char.IsLetterOrDigit(sql[i + 3])))
{
beginEndDepth--;
}
}
// 세미콜론으로 문장 분리 (BEGIN...END 블록 외부에서만)
if (c == ';' && beginEndDepth == 0)
{
currentStatement.Append(c);
string statement = currentStatement.ToString().Trim();
if (!string.IsNullOrWhiteSpace(statement))
{
statements.Add(statement);
}
currentStatement.Clear();
continue;
}
currentStatement.Append(c);
}
// 마지막 문장 추가 (세미콜론 없이 끝나는 경우)
string lastStatement = currentStatement.ToString().Trim();
if (!string.IsNullOrWhiteSpace(lastStatement))
{
statements.Add(lastStatement);
}
return statements;
}
#endregion
}
#region Result Structs
/// <summary>
/// 마이그레이션 결과
/// </summary>
public struct MigrationResult
{
public bool Success;
public string Message;
public string ErrorMessage;
public int MigrationsApplied;
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7efe4a498e5f5254ba0be804329e8fd1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1056308bc40a585459dd7361bc65fd71
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,358 @@
-- Migration 001: Initial Schema (SQLite)
-- Unity Editor Toolkit - SQLite Database Schema
-- Embedded SQLite (설치 불필요)
-- Created: 2025-11-14
-- Updated: 2025-11-14 (PostgreSQL → SQLite)
-- ============================================================
-- PRAGMA 설정
-- ============================================================
-- 참고: PRAGMA 설정은 SQLiteConnector.ConnectAsync()에서 자동으로 적용됩니다.
-- - foreign_keys = ON (Foreign Key 제약 활성화)
-- - journal_mode = WAL (Write-Ahead Logging, 성능 향상)
-- - synchronous = NORMAL (fsync 최적화)
-- ============================================================
-- TABLE 1: scenes
-- 씬 정보 (프로젝트의 모든 씬)
-- ============================================================
CREATE TABLE IF NOT EXISTS scenes (
scene_id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_name TEXT NOT NULL,
scene_path TEXT NOT NULL UNIQUE,
build_index INTEGER,
is_loaded INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER (0/1)
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_scenes_name ON scenes(scene_name);
CREATE INDEX idx_scenes_is_loaded ON scenes(is_loaded);
-- ============================================================
-- TABLE 2: gameobjects
-- GameObject 정보 (Closure Table로 계층 구조 관리)
-- ============================================================
CREATE TABLE IF NOT EXISTS gameobjects (
object_id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL UNIQUE,
scene_id INTEGER REFERENCES scenes(scene_id) ON DELETE CASCADE,
object_name TEXT NOT NULL,
parent_id INTEGER REFERENCES gameobjects(object_id) ON DELETE SET NULL,
tag TEXT DEFAULT 'Untagged',
layer INTEGER DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1, -- BOOLEAN → INTEGER
is_static INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER
is_deleted INTEGER NOT NULL DEFAULT 0, -- BOOLEAN → INTEGER (Soft delete)
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX idx_gameobjects_instance_id ON gameobjects(instance_id);
CREATE INDEX idx_gameobjects_scene_id ON gameobjects(scene_id);
CREATE INDEX idx_gameobjects_parent_id ON gameobjects(parent_id);
CREATE INDEX idx_gameobjects_tag ON gameobjects(tag);
CREATE INDEX idx_gameobjects_is_active ON gameobjects(is_active);
CREATE INDEX idx_gameobjects_is_deleted ON gameobjects(is_deleted);
-- ============================================================
-- TABLE 2-1: gameobject_closure
-- GameObject 계층 구조 (Closure Table)
-- PostgreSQL ltree 대체 - 성능이 더 우수함
-- ============================================================
CREATE TABLE IF NOT EXISTS gameobject_closure (
ancestor_id INTEGER NOT NULL,
descendant_id INTEGER NOT NULL,
depth INTEGER NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id),
FOREIGN KEY (ancestor_id) REFERENCES gameobjects(object_id) ON DELETE CASCADE,
FOREIGN KEY (descendant_id) REFERENCES gameobjects(object_id) ON DELETE CASCADE
);
-- 인덱스
CREATE INDEX idx_closure_ancestor ON gameobject_closure(ancestor_id);
CREATE INDEX idx_closure_descendant ON gameobject_closure(descendant_id);
CREATE INDEX idx_closure_depth ON gameobject_closure(depth);
-- ============================================================
-- TABLE 3: components
-- Component 정보
-- ============================================================
CREATE TABLE IF NOT EXISTS components (
component_id INTEGER PRIMARY KEY AUTOINCREMENT,
object_id INTEGER NOT NULL REFERENCES gameobjects(object_id) ON DELETE CASCADE,
component_type TEXT NOT NULL,
component_data TEXT CHECK(json_valid(component_data)), -- JSON validation
is_enabled INTEGER NOT NULL DEFAULT 1, -- BOOLEAN → INTEGER
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_components_object_id ON components(object_id);
CREATE INDEX idx_components_type ON components(component_type);
-- SQLite JSON index는 json_each/json_tree로 쿼리 시 자동 활용
-- ============================================================
-- TABLE 4: transforms
-- Transform 히스토리 (위치, 회전, 스케일 변경 추적)
-- ============================================================
CREATE TABLE IF NOT EXISTS transforms (
transform_id INTEGER PRIMARY KEY AUTOINCREMENT,
object_id INTEGER NOT NULL REFERENCES gameobjects(object_id) ON DELETE CASCADE,
position_x REAL NOT NULL,
position_y REAL NOT NULL,
position_z REAL NOT NULL,
rotation_x REAL NOT NULL, -- Quaternion X
rotation_y REAL NOT NULL, -- Quaternion Y
rotation_z REAL NOT NULL, -- Quaternion Z
rotation_w REAL NOT NULL, -- Quaternion W
scale_x REAL NOT NULL,
scale_y REAL NOT NULL,
scale_z REAL NOT NULL,
recorded_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_transforms_object_id ON transforms(object_id);
CREATE INDEX idx_transforms_recorded_at ON transforms(recorded_at);
-- ============================================================
-- TABLE 5: command_history
-- 명령 히스토리 (Command Pattern, Undo/Redo 지원)
-- ============================================================
CREATE TABLE IF NOT EXISTS command_history (
-- Primary Key: GUID 문자열 (C# Guid.NewGuid().ToString())
command_id TEXT PRIMARY KEY,
-- Command 정보
command_name TEXT NOT NULL,
command_type TEXT NOT NULL,
command_data TEXT NOT NULL CHECK(json_valid(command_data)), -- JSON validation
-- 실행 정보
executed_at TEXT NOT NULL,
executed_by TEXT DEFAULT 'System',
-- 메타데이터
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스
CREATE INDEX idx_command_history_executed_at ON command_history(executed_at DESC);
CREATE INDEX idx_command_history_type ON command_history(command_type);
CREATE INDEX idx_command_history_name ON command_history(command_name);
CREATE INDEX idx_command_history_executed_by ON command_history(executed_by);
-- ============================================================
-- TABLE 6: snapshots
-- 씬 스냅샷 (시점 복원용)
-- ============================================================
CREATE TABLE IF NOT EXISTS snapshots (
snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_id INTEGER NOT NULL REFERENCES scenes(scene_id) ON DELETE CASCADE,
snapshot_name TEXT NOT NULL,
snapshot_data TEXT NOT NULL CHECK(json_valid(snapshot_data)), -- JSON validation
description TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_snapshots_scene_id ON snapshots(scene_id);
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at);
-- ============================================================
-- TABLE 7: metadata
-- 프로젝트 메타데이터
-- ============================================================
CREATE TABLE IF NOT EXISTS metadata (
metadata_id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_metadata_key ON metadata(key);
-- ============================================================
-- TABLE 8: analytics_cache
-- 분석 결과 캐시 (성능 최적화)
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics_cache (
cache_id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT NOT NULL UNIQUE,
cache_data TEXT NOT NULL CHECK(json_valid(cache_data)), -- JSON validation
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_analytics_cache_key ON analytics_cache(cache_key);
CREATE INDEX idx_analytics_cache_expires_at ON analytics_cache(expires_at);
-- ============================================================
-- TRIGGERS
-- ============================================================
-- gameobjects.updated_at 자동 업데이트
CREATE TRIGGER IF NOT EXISTS trigger_gameobjects_updated_at
AFTER UPDATE ON gameobjects
FOR EACH ROW
BEGIN
UPDATE gameobjects SET updated_at = CURRENT_TIMESTAMP
WHERE object_id = NEW.object_id;
END;
-- components.updated_at 자동 업데이트
CREATE TRIGGER IF NOT EXISTS trigger_components_updated_at
AFTER UPDATE ON components
FOR EACH ROW
BEGIN
UPDATE components SET updated_at = CURRENT_TIMESTAMP
WHERE component_id = NEW.component_id;
END;
-- scenes.updated_at 자동 업데이트
CREATE TRIGGER IF NOT EXISTS trigger_scenes_updated_at
AFTER UPDATE ON scenes
FOR EACH ROW
BEGIN
UPDATE scenes SET updated_at = CURRENT_TIMESTAMP
WHERE scene_id = NEW.scene_id;
END;
-- metadata.updated_at 자동 업데이트
CREATE TRIGGER IF NOT EXISTS trigger_metadata_updated_at
AFTER UPDATE ON metadata
FOR EACH ROW
BEGIN
UPDATE metadata SET updated_at = CURRENT_TIMESTAMP
WHERE metadata_id = NEW.metadata_id;
END;
-- ============================================================
-- CLOSURE TABLE TRIGGERS (자동 유지 관리)
-- ============================================================
-- GameObject 생성 시: 자기 자신 + 부모 조상들 추가
CREATE TRIGGER IF NOT EXISTS trigger_gameobject_insert_closure
AFTER INSERT ON gameobjects
FOR EACH ROW
BEGIN
-- 1. 자기 자신 (depth = 0)
INSERT INTO gameobject_closure (ancestor_id, descendant_id, depth)
VALUES (NEW.object_id, NEW.object_id, 0);
-- 2. 부모가 있으면 부모의 모든 조상 복사 (depth + 1)
INSERT INTO gameobject_closure (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.object_id, depth + 1
FROM gameobject_closure
WHERE descendant_id = NEW.parent_id AND NEW.parent_id IS NOT NULL;
END;
-- GameObject 삭제 시: 관련 Closure 레코드 자동 삭제 (CASCADE)
-- (FOREIGN KEY ON DELETE CASCADE가 처리)
-- ============================================================
-- VIEWS
-- ============================================================
-- Active GameObjects View (is_deleted = 0, is_active = 1)
CREATE VIEW IF NOT EXISTS active_gameobjects AS
SELECT
object_id,
instance_id,
scene_id,
object_name,
parent_id,
tag,
layer,
created_at,
updated_at
FROM gameobjects
WHERE is_deleted = 0 AND is_active = 1;
-- GameObject Component Count View
CREATE VIEW IF NOT EXISTS gameobject_component_count AS
SELECT
g.object_id,
g.object_name,
g.parent_id,
COUNT(c.component_id) AS component_count
FROM gameobjects g
LEFT JOIN components c ON g.object_id = c.object_id
WHERE g.is_deleted = 0
GROUP BY g.object_id, g.object_name, g.parent_id;
-- Recent Commands View
CREATE VIEW IF NOT EXISTS recent_commands AS
SELECT
command_id,
command_name,
command_type,
executed_at,
executed_by
FROM command_history
ORDER BY executed_at DESC
LIMIT 100;
-- Command Statistics View
CREATE VIEW IF NOT EXISTS command_statistics AS
SELECT
command_type,
COUNT(*) AS command_count,
MIN(executed_at) AS first_executed,
MAX(executed_at) AS last_executed
FROM command_history
GROUP BY command_type
ORDER BY command_count DESC;
-- ============================================================
-- INITIAL DATA
-- ============================================================
-- 기본 메타데이터 삽입
INSERT OR IGNORE INTO metadata (key, value) VALUES
('schema_version', '1'),
('database_type', 'SQLite'),
('database_created_at', datetime('now')),
('unity_editor_toolkit_version', '0.5.0');
-- ============================================================
-- COMPLETION
-- ============================================================
-- SQLite는 RAISE NOTICE가 없으므로 SELECT로 완료 메시지 출력
SELECT '==========================================================' AS message;
SELECT 'Migration 001: Initial Schema (SQLite) - 완료' AS message;
SELECT '==========================================================' AS message;
SELECT '생성된 테이블: 9개' AS message;
SELECT ' - scenes, gameobjects, gameobject_closure, components' AS message;
SELECT ' - transforms, command_history, snapshots, metadata, analytics_cache' AS message;
SELECT '' AS message;
SELECT '생성된 인덱스: 23개' AS message;
SELECT ' - gameobject_closure: 3개 (ancestor, descendant, depth)' AS message;
SELECT ' - command_history: 4개 (GUID, executed_at, type, name, executed_by)' AS message;
SELECT '' AS message;
SELECT '생성된 트리거: 5개' AS message;
SELECT ' - updated_at 자동 업데이트 (gameobjects, components, scenes, metadata)' AS message;
SELECT ' - Closure Table 자동 유지 (gameobject_insert_closure)' AS message;
SELECT '' AS message;
SELECT '생성된 뷰: 4개' AS message;
SELECT ' - active_gameobjects, gameobject_component_count' AS message;
SELECT ' - recent_commands, command_statistics' AS message;
SELECT '' AS message;
SELECT 'Closure Table 계층 구조 쿼리 예시:' AS message;
SELECT ' -- 특정 GameObject의 모든 자식 (ltree 대체):' AS message;
SELECT ' SELECT g.* FROM gameobjects g' AS message;
SELECT ' INNER JOIN gameobject_closure c ON g.object_id = c.descendant_id' AS message;
SELECT ' WHERE c.ancestor_id = ? AND c.depth > 0;' AS message;
SELECT '==========================================================' AS message;

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5345bdb2f717d2e409cdd34c2a7262a3
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,31 @@
-- Migration 002: Add GUID to GameObject
-- Unity Editor Toolkit - Add persistent GUID column to gameobjects table
-- Created: 2025-11-18
-- Purpose: Replace instance_id with GUID for persistent GameObject identification
-- ============================================================
-- ALTER TABLE: gameobjects
-- Add guid column for persistent identification
-- ============================================================
-- Add guid column (nullable initially for migration)
ALTER TABLE gameobjects ADD COLUMN guid TEXT;
-- Create unique index on guid (after data migration)
-- Note: Index will be created after populating existing rows with GUIDs
-- CREATE UNIQUE INDEX idx_gameobjects_guid ON gameobjects(guid);
-- ============================================================
-- Migration Notes
-- ============================================================
--
-- GUID migration strategy:
-- 1. Add guid column (nullable)
-- 2. Existing rows will have NULL guid
-- 3. SyncManager will generate GUIDs when syncing GameObjects
-- 4. GameObjectGuid component ensures all GameObjects have GUIDs
-- 5. After full scene sync, make guid NOT NULL and UNIQUE
--
-- Future migration (Migration_003):
-- ALTER TABLE gameobjects ALTER COLUMN guid SET NOT NULL;
-- CREATE UNIQUE INDEX idx_gameobjects_guid ON gameobjects(guid);

View File

@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: d06228fa3660f4f43a33b92f2cc4e994
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: ba03993677dd740da91b9a13653538e9, type: 3}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b6b69cd3d00bc704c863538202e5efb3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8952d02cfc5f21e4b82dc98c5e3e0d21
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,344 @@
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using SQLite;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database
{
/// <summary>
/// SQLite 데이터베이스 커넥터
/// 임베디드 SQLite - 설치 불필요
/// </summary>
public class SQLiteConnector
{
#region Fields
private readonly DatabaseConfig config;
private SQLiteConnection connection;
private bool isConnected;
#endregion
#region Properties
/// <summary>
/// 연결 상태
/// </summary>
public bool IsConnected => isConnected && connection != null;
/// <summary>
/// SQLite 연결 객체
/// </summary>
public SQLiteConnection Connection => connection;
#endregion
#region Constructor
public SQLiteConnector(DatabaseConfig config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
}
#endregion
#region Connection Management
/// <summary>
/// 데이터베이스 연결
/// </summary>
public async UniTask<ConnectionResult> ConnectAsync(CancellationToken cancellationToken = default)
{
try
{
if (isConnected)
{
ToolkitLogger.LogWarning("SQLiteConnector", "Already connected.");
return new ConnectionResult
{
Success = true,
Message = "Already connected."
};
}
ToolkitLogger.Log("SQLiteConnector", $" Connecting to: {config.DatabaseFilePath}");
// SQLite 연결 생성 (동기 작업이지만 UniTask로 래핑)
await UniTask.RunOnThreadPool(() =>
{
// SQLite 연결 옵션 설정
var options = new SQLiteConnectionString(
config.DatabaseFilePath,
SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex,
storeDateTimeAsTicks: true
);
connection = new SQLiteConnection(options);
// WAL 모드 설정 (성능 향상)
if (config.EnableWAL)
{
try
{
connection.Execute("PRAGMA journal_mode=WAL;");
ToolkitLogger.Log("SQLiteConnector", "WAL mode enabled.");
}
catch (Exception walEx)
{
ToolkitLogger.LogWarning("SQLiteConnector", $" WAL mode failed (continuing without WAL): {walEx.Message}");
}
}
// Foreign Keys 활성화
try
{
connection.Execute("PRAGMA foreign_keys=ON;");
}
catch (Exception fkEx)
{
ToolkitLogger.LogWarning("SQLiteConnector", $" Foreign keys activation failed: {fkEx.Message}");
}
// Synchronous 설정 (NORMAL = 안전하면서도 빠름)
try
{
connection.Execute("PRAGMA synchronous=NORMAL;");
}
catch (Exception syncEx)
{
ToolkitLogger.LogWarning("SQLiteConnector", $" Synchronous setting failed: {syncEx.Message}");
}
}, cancellationToken: cancellationToken);
isConnected = true;
ToolkitLogger.Log("SQLiteConnector", $" Connected successfully: {config.DatabaseFilePath}");
return new ConnectionResult
{
Success = true,
Message = "Connection successful."
};
}
catch (OperationCanceledException)
{
ToolkitLogger.LogWarning("SQLiteConnector", "Connection cancelled.");
return new ConnectionResult
{
Success = false,
ErrorMessage = "Connection cancelled."
};
}
catch (Exception ex)
{
ToolkitLogger.LogError("SQLiteConnector", $" Connection failed: {ex.Message}\n{ex.StackTrace}");
return new ConnectionResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 데이터베이스 연결 해제 (비동기)
/// </summary>
public async UniTask DisconnectAsync()
{
try
{
if (!isConnected)
{
return;
}
ToolkitLogger.Log("SQLiteConnector", "Disconnecting...");
await UniTask.RunOnThreadPool(() =>
{
DisconnectInternal();
});
isConnected = false;
// 강제 GC (파일 핸들 해제를 위해)
GC.Collect();
GC.WaitForPendingFinalizers();
ToolkitLogger.Log("SQLiteConnector", "Disconnected.");
}
catch (Exception ex)
{
ToolkitLogger.LogError("SQLiteConnector", $" Disconnect error: {ex.Message}");
}
}
/// <summary>
/// 데이터베이스 연결 해제 (동기 - Assembly Reload/서버 종료 시 사용)
/// </summary>
public void Disconnect()
{
try
{
if (!isConnected)
{
return;
}
ToolkitLogger.Log("SQLiteConnector", "Disconnecting (sync)...");
DisconnectInternal();
isConnected = false;
ToolkitLogger.Log("SQLiteConnector", "Disconnected (sync).");
}
catch (Exception ex)
{
ToolkitLogger.LogError("SQLiteConnector", $" Disconnect error: {ex.Message}");
}
}
/// <summary>
/// 내부 연결 해제 로직
/// </summary>
private void DisconnectInternal()
{
// WAL 체크포인트 수행 (파일 잠금 해제를 위해)
if (config.EnableWAL && connection != null)
{
try
{
connection.Execute("PRAGMA wal_checkpoint(TRUNCATE);");
ToolkitLogger.Log("SQLiteConnector", "WAL checkpoint completed.");
}
catch (Exception walEx)
{
ToolkitLogger.LogWarning("SQLiteConnector", $" WAL checkpoint failed: {walEx.Message}");
}
}
connection?.Close();
connection?.Dispose();
connection = null;
}
/// <summary>
/// 연결 테스트
/// </summary>
public async UniTask<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
try
{
if (!isConnected || connection == null)
{
return false;
}
// 간단한 쿼리로 연결 테스트
await UniTask.RunOnThreadPool(() =>
{
connection.ExecuteScalar<int>("SELECT 1;");
}, cancellationToken: cancellationToken);
return true;
}
catch (Exception ex)
{
ToolkitLogger.LogError("SQLiteConnector", $" Connection test failed: {ex.Message}");
return false;
}
}
#endregion
#region Database Operations
/// <summary>
/// SQL 스크립트 실행 (마이그레이션용)
/// </summary>
public async UniTask<int> ExecuteScriptAsync(string sql, CancellationToken cancellationToken = default)
{
if (!isConnected || connection == null)
{
throw new InvalidOperationException("Database is not connected.");
}
try
{
return await UniTask.RunOnThreadPool(() =>
{
return connection.Execute(sql);
}, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
ToolkitLogger.LogError("SQLiteConnector", $" Execute script failed: {ex.Message}\n{sql}");
throw;
}
}
/// <summary>
/// 단일 값 조회
/// </summary>
public async UniTask<T> ExecuteScalarAsync<T>(string sql, CancellationToken cancellationToken = default)
{
if (!isConnected || connection == null)
{
throw new InvalidOperationException("Database is not connected.");
}
try
{
return await UniTask.RunOnThreadPool(() =>
{
return connection.ExecuteScalar<T>(sql);
}, cancellationToken: cancellationToken);
}
catch (Exception ex)
{
ToolkitLogger.LogError("SQLiteConnector", $" Execute scalar failed: {ex.Message}\n{sql}");
throw;
}
}
/// <summary>
/// 데이터베이스 파일 존재 여부 확인
/// </summary>
public bool DatabaseFileExists()
{
return System.IO.File.Exists(config.DatabaseFilePath);
}
/// <summary>
/// SQLite 버전 조회
/// </summary>
public async UniTask<string> GetDatabaseVersionAsync()
{
if (!isConnected || connection == null)
{
return "Not Connected";
}
try
{
return await UniTask.RunOnThreadPool(() =>
{
var result = connection.ExecuteScalar<string>("SELECT sqlite_version();");
return $"SQLite {result}";
});
}
catch (Exception ex)
{
ToolkitLogger.LogError("SQLiteConnector", $" Failed to get version: {ex.Message}");
return "Unknown";
}
}
#endregion
}
#region Result Struct
/// <summary>
/// 연결 결과
/// </summary>
public struct ConnectionResult
{
public bool Success;
public string Message;
public string ErrorMessage;
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ad42d3641d3371b4d890f2ebed149635

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b8dc1710d9b974e47a258b98c60eb5a6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,182 @@
using System;
using System.IO;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Setup
{
/// <summary>
/// SQLite 데이터베이스 자동 생성
/// 파일 기반 DB이므로 설치 불필요
/// </summary>
public class DatabaseCreator
{
/// <summary>
/// 데이터베이스 파일 생성 및 확인
/// </summary>
public async UniTask<DatabaseCreationResult> CreateDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
{
try
{
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 확인 시작: {config.DatabaseFilePath}");
// 1. 데이터베이스 파일 존재 여부 확인
if (File.Exists(config.DatabaseFilePath))
{
long fileSize = new FileInfo(config.DatabaseFilePath).Length;
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 파일이 이미 존재합니다: {config.DatabaseFilePath} ({fileSize} bytes)");
return new DatabaseCreationResult
{
Success = true,
Message = $"Database file already exists: {config.DatabaseFilePath}",
AlreadyExists = true
};
}
// 2. 디렉토리 생성 (존재하지 않으면)
string directory = Path.GetDirectoryName(config.DatabaseFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
ToolkitLogger.Log("DatabaseCreator", $" 디렉토리 생성: {directory}");
Directory.CreateDirectory(directory);
}
// 3. SQLite 파일은 첫 연결 시 자동 생성됨
// 여기서는 빈 파일을 생성하지 않고, 연결 시 자동 생성되도록 함
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 준비 완료: {config.DatabaseFilePath}");
ToolkitLogger.Log("DatabaseCreator", "첫 연결 시 자동으로 생성됩니다.");
await UniTask.Yield(cancellationToken);
return new DatabaseCreationResult
{
Success = true,
Message = $"Database ready to be created: {config.DatabaseFilePath}",
AlreadyExists = false
};
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseCreator", $" 예외 발생: {ex.Message}\n{ex.StackTrace}");
return new DatabaseCreationResult
{
Success = false,
ErrorMessage = ex.Message,
AlreadyExists = false
};
}
}
/// <summary>
/// 데이터베이스 파일 삭제 (개발/테스트용)
/// </summary>
public async UniTask<bool> DeleteDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
{
try
{
if (!File.Exists(config.DatabaseFilePath))
{
ToolkitLogger.LogWarning("DatabaseCreator", $" 데이터베이스 파일이 존재하지 않습니다: {config.DatabaseFilePath}");
return true;
}
ToolkitLogger.Log("DatabaseCreator", $" 데이터베이스 삭제: {config.DatabaseFilePath}");
File.Delete(config.DatabaseFilePath);
// WAL 파일도 삭제 (있으면)
string walFile = config.DatabaseFilePath + "-wal";
string shmFile = config.DatabaseFilePath + "-shm";
if (File.Exists(walFile))
{
File.Delete(walFile);
ToolkitLogger.Log("DatabaseCreator", $" WAL 파일 삭제: {walFile}");
}
if (File.Exists(shmFile))
{
File.Delete(shmFile);
ToolkitLogger.Log("DatabaseCreator", $" SHM 파일 삭제: {shmFile}");
}
await UniTask.Yield(cancellationToken);
ToolkitLogger.Log("DatabaseCreator", "데이터베이스 삭제 완료.");
return true;
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseCreator", $" 삭제 실패: {ex.Message}");
return false;
}
}
/// <summary>
/// 데이터베이스 파일 정보 조회
/// </summary>
public DatabaseFileInfo GetDatabaseInfo(DatabaseConfig config)
{
if (!File.Exists(config.DatabaseFilePath))
{
return new DatabaseFileInfo
{
Exists = false,
FilePath = config.DatabaseFilePath
};
}
var fileInfo = new FileInfo(config.DatabaseFilePath);
return new DatabaseFileInfo
{
Exists = true,
FilePath = config.DatabaseFilePath,
FileSize = fileInfo.Length,
CreatedTime = fileInfo.CreationTime,
ModifiedTime = fileInfo.LastWriteTime
};
}
}
#region Result Structs
/// <summary>
/// 데이터베이스 생성 결과
/// </summary>
public struct DatabaseCreationResult
{
public bool Success;
public string Message;
public string ErrorMessage;
public bool AlreadyExists;
}
/// <summary>
/// 데이터베이스 파일 정보
/// </summary>
public struct DatabaseFileInfo
{
public bool Exists;
public string FilePath;
public long FileSize;
public DateTime CreatedTime;
public DateTime ModifiedTime;
public override string ToString()
{
if (!Exists)
{
return $"[DatabaseFileInfo] File does not exist: {FilePath}";
}
return $"[DatabaseFileInfo]\n" +
$" Path: {FilePath}\n" +
$" Size: {FileSize / 1024.0:F2} KB\n" +
$" Created: {CreatedTime}\n" +
$" Modified: {ModifiedTime}";
}
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dea57a163338a004098266d197d522dd

View File

@@ -0,0 +1,222 @@
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database.Setup
{
/// <summary>
/// Database 원클릭 자동 설치 마법사
/// SQLite - 설치 불필요, DB 파일 생성 → 마이그레이션
/// </summary>
public class DatabaseSetupWizard
{
#region Setup Steps
public enum SetupStep
{
NotStarted,
PreparingDatabase,
RunningMigrations,
Completed,
Failed
}
#endregion
#region State
private SetupStep currentStep = SetupStep.NotStarted;
private string statusMessage = "";
private string errorMessage = "";
private bool isRunning = false;
private float progress = 0f;
public SetupStep CurrentStep => currentStep;
public string StatusMessage => statusMessage;
public string ErrorMessage => errorMessage;
public bool IsRunning => isRunning;
public float Progress => progress;
#endregion
#region Dependencies
private readonly DatabaseCreator databaseCreator;
#endregion
#region Constructor
public DatabaseSetupWizard()
{
databaseCreator = new DatabaseCreator();
}
#endregion
#region Main Setup Flow
/// <summary>
/// 자동 설치 시작
/// </summary>
public async UniTask<SetupResult> RunSetupAsync(DatabaseConfig config, CancellationToken cancellationToken = default)
{
if (isRunning)
{
return new SetupResult
{
Success = false,
ErrorMessage = "Setup already in progress."
};
}
isRunning = true;
currentStep = SetupStep.NotStarted;
errorMessage = "";
progress = 0f;
try
{
// Step 1: 데이터베이스 파일 준비
if (!await PrepareDatabaseAsync(config, cancellationToken))
{
return CreateFailureResult("데이터베이스 준비 실패.");
}
// Step 2: 마이그레이션 실행
if (!await RunMigrationsAsync(config, cancellationToken))
{
return CreateFailureResult("마이그레이션 실행 실패.");
}
// 완료
currentStep = SetupStep.Completed;
statusMessage = "데이터베이스 설치 완료!";
progress = 1f;
ToolkitLogger.Log("DatabaseSetupWizard", "설치 완료!");
return new SetupResult
{
Success = true,
Message = "Database setup completed successfully!"
};
}
catch (OperationCanceledException)
{
ToolkitLogger.LogWarning("DatabaseSetupWizard", "설치가 취소되었습니다.");
currentStep = SetupStep.Failed;
return CreateFailureResult("설치가 취소되었습니다.");
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseSetupWizard", $" 설치 중 예외 발생: {ex.Message}");
currentStep = SetupStep.Failed;
return CreateFailureResult($"예외 발생: {ex.Message}");
}
finally
{
isRunning = false;
}
}
#endregion
#region Step Implementations
private async UniTask<bool> PrepareDatabaseAsync(DatabaseConfig config, CancellationToken cancellationToken)
{
currentStep = SetupStep.PreparingDatabase;
statusMessage = "데이터베이스 파일 준비 중...";
progress = 0.3f;
ToolkitLogger.Log("DatabaseSetupWizard", "데이터베이스 파일 준비 중...");
var result = await databaseCreator.CreateDatabaseAsync(config, cancellationToken);
if (result.Success)
{
if (result.AlreadyExists)
{
ToolkitLogger.Log("DatabaseSetupWizard", $" 데이터베이스 파일이 이미 존재합니다: {config.DatabaseFilePath}");
}
else
{
ToolkitLogger.Log("DatabaseSetupWizard", $" 데이터베이스 파일 준비 완료: {config.DatabaseFilePath}");
}
return true;
}
else
{
errorMessage = $"데이터베이스 준비 실패: {result.ErrorMessage}";
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
return false;
}
}
private async UniTask<bool> RunMigrationsAsync(DatabaseConfig config, CancellationToken cancellationToken)
{
currentStep = SetupStep.RunningMigrations;
statusMessage = "마이그레이션 실행 중...";
progress = 0.6f;
ToolkitLogger.Log("DatabaseSetupWizard", "마이그레이션 시작...");
try
{
// DatabaseManager 초기화 (InitializeAsync가 자동으로 마이그레이션도 실행함)
var initResult = await DatabaseManager.Instance.InitializeAsync(config);
if (!initResult.Success)
{
errorMessage = $"DatabaseManager 초기화 실패: {initResult.ErrorMessage}";
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
return false;
}
// 마이그레이션은 InitializeAsync에서 자동으로 실행됨
// 중복 실행을 방지하기 위해 별도 호출하지 않음
ToolkitLogger.Log("DatabaseSetupWizard", "마이그레이션 완료 (DatabaseManager.InitializeAsync에서 실행됨)");
return true;
}
catch (Exception ex)
{
errorMessage = $"마이그레이션 중 예외 발생: {ex.Message}";
ToolkitLogger.LogError("DatabaseSetupWizard", $" {errorMessage}");
return false;
}
}
#endregion
#region Helper Methods
private SetupResult CreateFailureResult(string error)
{
currentStep = SetupStep.Failed;
errorMessage = error;
progress = 0f;
return new SetupResult
{
Success = false,
ErrorMessage = error
};
}
#endregion
#region Reset
/// <summary>
/// 상태 초기화
/// </summary>
public void Reset()
{
currentStep = SetupStep.NotStarted;
statusMessage = "";
errorMessage = "";
isRunning = false;
progress = 0f;
}
#endregion
}
#region Result Struct
/// <summary>
/// Setup 결과
/// </summary>
public struct SetupResult
{
public bool Success;
public string Message;
public string ErrorMessage;
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64255a616a004d548b7250bb0302633f

View File

@@ -0,0 +1,866 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditorToolkit.Runtime;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor.Database
{
/// <summary>
/// Unity ↔ PostgreSQL 실시간 동기화 관리자
/// Phase 1: 기본 동기화 프레임워크
/// Phase 2+: GameObject/Component 실시간 추적, 배치 업데이트
/// </summary>
public class SyncManager : IDisposable
{
#region Fields
private readonly DatabaseManager databaseManager;
private bool isRunning = false;
private bool isDisposed = false;
private CancellationTokenSource syncCts;
// 동기화 설정
private const int SyncIntervalMilliseconds = 1000; // 1초마다 동기화
private const int BatchSize = 500; // 배치당 최대 500개 객체
#endregion
#region Properties
/// <summary>
/// 동기화 실행 중 여부
/// </summary>
public bool IsRunning => isRunning;
/// <summary>
/// 마지막 동기화 시간
/// </summary>
public DateTime LastSyncTime { get; private set; }
/// <summary>
/// 동기화 성공 횟수
/// </summary>
public int SuccessfulSyncCount { get; private set; }
/// <summary>
/// 동기화 실패 횟수
/// </summary>
public int FailedSyncCount { get; private set; }
#endregion
#region Constructor
public SyncManager(DatabaseManager databaseManager)
{
this.databaseManager = databaseManager ?? throw new ArgumentNullException(nameof(databaseManager));
LastSyncTime = DateTime.MinValue;
SuccessfulSyncCount = 0;
FailedSyncCount = 0;
ToolkitLogger.Log("SyncManager", "생성 완료.");
}
#endregion
#region Sync Control
/// <summary>
/// 동기화 시작
/// </summary>
public void StartSync()
{
ThrowIfDisposed();
if (isRunning)
{
ToolkitLogger.LogWarning("SyncManager", "이미 동기화가 실행 중입니다.");
return;
}
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
{
ToolkitLogger.LogError("SyncManager", "DatabaseManager가 초기화되지 않았거나 연결되지 않았습니다.");
return;
}
ToolkitLogger.Log("SyncManager", "동기화 시작...");
syncCts = new CancellationTokenSource();
isRunning = true;
// 백그라운드 동기화 루프 시작 (UniTask)
RunSyncLoopAsync(syncCts.Token).Forget();
}
/// <summary>
/// 동기화 중지
/// </summary>
public void StopSync()
{
ThrowIfDisposed();
if (!isRunning)
{
return;
}
ToolkitLogger.Log("SyncManager", "동기화 중지 중...");
syncCts?.Cancel();
syncCts?.Dispose();
syncCts = null;
isRunning = false;
ToolkitLogger.Log("SyncManager", "동기화 중지 완료.");
}
#endregion
#region Sync Loop
/// <summary>
/// 백그라운드 동기화 루프 (UniTask)
/// </summary>
private async UniTaskVoid RunSyncLoopAsync(CancellationToken cancellationToken)
{
ToolkitLogger.Log("SyncManager", "동기화 루프 시작.");
try
{
while (!cancellationToken.IsCancellationRequested)
{
// 동기화 수행
await PerformSyncAsync(cancellationToken);
// 대기 (1초)
await UniTask.Delay(SyncIntervalMilliseconds, cancellationToken: cancellationToken);
}
}
catch (OperationCanceledException)
{
ToolkitLogger.Log("SyncManager", "동기화 루프가 취소되었습니다.");
}
catch (Exception ex)
{
ToolkitLogger.LogError("SyncManager", $" 동기화 루프 중 예외 발생: {ex.Message}\n{ex.StackTrace}");
isRunning = false;
}
ToolkitLogger.Log("SyncManager", "동기화 루프 종료.");
}
/// <summary>
/// 단일 동기화 수행 (모든 로드된 씬)
/// </summary>
private async UniTask PerformSyncAsync(CancellationToken cancellationToken)
{
try
{
// Phase 1: 단순 연결 테스트만 수행
// Phase 2: GameObject 변경 감지 및 배치 업데이트
bool isConnected = await databaseManager.TestConnectionAsync();
if (!isConnected)
{
ToolkitLogger.LogWarning("SyncManager", "데이터베이스 연결이 끊어졌습니다.");
FailedSyncCount++;
return;
}
// Phase 2: 모든 로드된 씬에 대해 GameObject 변경 감지 및 배치 업데이트
int sceneCount = SceneManager.sceneCount;
if (sceneCount == 0)
{
ToolkitLogger.LogWarning("SyncManager", "로드된 씬이 없습니다.");
return;
}
// 모든 로드된 씬 순회
for (int i = 0; i < sceneCount; i++)
{
var scene = SceneManager.GetSceneAt(i);
// 로드되지 않은 씬은 건너뛰기
if (!scene.isLoaded)
{
continue;
}
// 1. Unity Scene에서 모든 GameObject 수집
var allGameObjects = CollectAllGameObjects(scene);
if (allGameObjects.Count == 0)
{
continue; // 빈 씬은 건너뛰기
}
// 2. DB에서 현재 씬의 GameObject 목록 가져오기
Dictionary<string, DbGameObject> dbGameObjects;
try
{
dbGameObjects = await GetDatabaseGameObjectsAsync(scene, cancellationToken);
}
catch (InvalidOperationException ex)
{
ToolkitLogger.LogError("SyncManager", $" DB 연결 실패로 씬 '{scene.name}' 동기화 건너뜀: {ex.Message}");
continue; // 이 씬은 건너뛰고 다음 씬 처리
}
// 3. 변경 감지
var changes = DetectChanges(allGameObjects, dbGameObjects);
// 4. 배치 업데이트 실행
if (changes.Updated.Count > 0)
{
await BatchUpdateGameObjectsAsync(changes.Updated, cancellationToken);
}
if (changes.Inserted.Count > 0)
{
await BatchInsertGameObjectsAsync(scene, changes.Inserted, cancellationToken);
}
if (changes.Deleted.Count > 0)
{
await BatchMarkDeletedAsync(changes.Deleted, cancellationToken);
}
// 취소 확인
cancellationToken.ThrowIfCancellationRequested();
}
LastSyncTime = DateTime.UtcNow;
SuccessfulSyncCount++;
}
catch (OperationCanceledException)
{
throw; // 취소는 상위로 전파
}
catch (Exception ex)
{
ToolkitLogger.LogError("SyncManager", $" 동기화 중 예외 발생: {ex.Message}");
FailedSyncCount++;
}
}
#endregion
#region Manual Sync
/// <summary>
/// 수동 동기화 (즉시 실행)
/// </summary>
public async UniTask<SyncResult> SyncNowAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (!databaseManager.IsInitialized || !databaseManager.IsConnected)
{
return new SyncResult
{
Success = false,
ErrorMessage = "Database not initialized or not connected."
};
}
try
{
ToolkitLogger.Log("SyncManager", "수동 동기화 시작...");
await PerformSyncAsync(cancellationToken);
ToolkitLogger.Log("SyncManager", "수동 동기화 완료.");
return new SyncResult
{
Success = true,
Message = "Manual sync completed successfully."
};
}
catch (OperationCanceledException)
{
ToolkitLogger.LogWarning("SyncManager", "수동 동기화가 취소되었습니다.");
return new SyncResult
{
Success = false,
ErrorMessage = "Manual sync was canceled."
};
}
catch (Exception ex)
{
ToolkitLogger.LogError("SyncManager", $" 수동 동기화 중 예외 발생: {ex.Message}");
return new SyncResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
#endregion
#region Batch Operations (Phase 2+)
/// <summary>
/// GameObject 배치 업데이트
/// </summary>
/// <param name="gameObjects">업데이트할 GameObject 목록</param>
public async UniTask<int> BatchUpdateGameObjectsAsync(List<GameObject> gameObjects, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (gameObjects == null || gameObjects.Count == 0)
{
return 0;
}
await UniTask.SwitchToThreadPool();
try
{
var connection = databaseManager.Connector?.Connection;
if (connection == null)
{
ToolkitLogger.LogError("SyncManager", "Database connection is null");
return 0;
}
int updatedCount = 0;
// 배치 크기로 나누어 처리
for (int i = 0; i < gameObjects.Count; i += BatchSize)
{
int batchCount = Math.Min(BatchSize, gameObjects.Count - i);
var batch = gameObjects.GetRange(i, batchCount);
ExecuteInTransaction(connection, () =>
{
foreach (var obj in batch)
{
var guidComp = EnsureGameObjectGuid(obj);
string guid = guidComp.Guid;
int instanceId = obj.GetInstanceID();
int? parentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
var sql = @"
UPDATE gameobjects
SET object_name = ?,
parent_id = ?,
tag = ?,
layer = ?,
is_active = ?,
is_static = ?,
instance_id = ?,
updated_at = datetime('now', 'localtime')
WHERE guid = ?
";
connection.Execute(sql, obj.name, parentId, obj.tag, obj.layer, obj.activeSelf, obj.isStatic, instanceId, guid);
}
});
updatedCount += batchCount;
ToolkitLogger.Log("SyncManager", $" 배치 업데이트 완료: {batchCount}개 GameObject");
// 취소 확인
cancellationToken.ThrowIfCancellationRequested();
}
return updatedCount;
}
finally
{
// 메인 스레드가 살아있는지 확인
if (!isDisposed)
{
await UniTask.SwitchToMainThread();
}
}
}
/// <summary>
/// Component 배치 업데이트 (Phase 2에서 구현)
/// </summary>
public async UniTask<int> BatchUpdateComponentsAsync(List<Component> components, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (components == null || components.Count == 0)
{
return 0;
}
// TODO Phase 2: Component 배치 업데이트 구현
await UniTask.Yield();
return 0;
}
#endregion
#region Helper Methods (Phase 2)
/// <summary>
/// 트랜잭션을 안전하게 실행하는 헬퍼 메서드
/// </summary>
private void ExecuteInTransaction(SQLite.SQLiteConnection connection, Action action)
{
// SQLite는 중첩 트랜잭션을 지원하지 않으므로 try-catch로 감지
bool transactionStarted = false;
try
{
connection.BeginTransaction();
transactionStarted = true;
action();
connection.Commit();
}
catch (InvalidOperationException ex) when (ex.Message.Contains("transaction") || ex.Message.Contains("Transaction"))
{
// 이미 트랜잭션이 시작된 경우, 그냥 액션만 실행
ToolkitLogger.LogWarning("SyncManager", $" Transaction already started, executing without nested transaction: {ex.Message}");
if (transactionStarted)
{
connection.Rollback();
}
action();
}
catch
{
if (transactionStarted)
{
connection.Rollback();
}
throw;
}
}
/// <summary>
/// Unity Scene에서 모든 GameObject 재귀적으로 수집
/// </summary>
private List<GameObject> CollectAllGameObjects(Scene scene)
{
var result = new List<GameObject>();
var rootObjects = scene.GetRootGameObjects();
foreach (var root in rootObjects)
{
CollectGameObjectsRecursive(root, result);
}
return result;
}
/// <summary>
/// GameObject를 재귀적으로 수집하는 헬퍼 메서드
/// </summary>
private void CollectGameObjectsRecursive(GameObject obj, List<GameObject> list)
{
list.Add(obj);
for (int i = 0; i < obj.transform.childCount; i++)
{
CollectGameObjectsRecursive(obj.transform.GetChild(i).gameObject, list);
}
}
/// <summary>
/// DB에서 현재 씬의 GameObject 목록 가져오기 (GUID 기반)
/// </summary>
private async UniTask<Dictionary<string, DbGameObject>> GetDatabaseGameObjectsAsync(Scene scene, CancellationToken cancellationToken)
{
await UniTask.SwitchToThreadPool();
try
{
var connection = databaseManager.Connector?.Connection;
if (connection == null)
{
ToolkitLogger.LogError("SyncManager", "Database connection is null");
throw new InvalidOperationException("Database connection is not available");
}
// 1. scene_id 가져오기
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
if (!sceneIds.Any())
{
// Scene이 DB에 없는 것은 정상 (첫 동기화)
ToolkitLogger.Log("SyncManager", $" Scene '{scene.name}'이 DB에 없습니다 (첫 동기화).");
return new Dictionary<string, DbGameObject>();
}
int sceneId = sceneIds.First().scene_id;
// 2. GameObject 목록 가져오기 (guid 포함)
var sql = @"
SELECT object_id, instance_id, guid, object_name, parent_id, tag, layer, is_active, is_static, is_deleted
FROM gameobjects
WHERE scene_id = ? AND is_deleted = 0
";
var dbObjects = connection.Query<DbGameObject>(sql, sceneId);
// Dictionary로 변환 (guid를 키로 사용, guid가 null인 경우 instance_id를 fallback으로 사용)
var result = new Dictionary<string, DbGameObject>();
foreach (var obj in dbObjects)
{
string key = !string.IsNullOrEmpty(obj.guid) ? obj.guid : $"instance_{obj.instance_id}";
result[key] = obj;
}
return result;
}
finally
{
// 메인 스레드가 살아있는지 확인
if (!isDisposed)
{
await UniTask.SwitchToMainThread();
}
}
}
/// <summary>
/// Scene ID를 가져오기 위한 레코드 클래스
/// </summary>
private class SceneIdRecord
{
public int scene_id { get; set; }
}
#endregion
#region Change Detection (Phase 2)
/// <summary>
/// GameObject에 GameObjectGuid 컴포넌트 확보 (없으면 추가)
/// </summary>
private GameObjectGuid EnsureGameObjectGuid(GameObject obj)
{
var guidComp = obj.GetComponent<GameObjectGuid>();
if (guidComp == null)
{
guidComp = obj.AddComponent<GameObjectGuid>();
}
return guidComp;
}
/// <summary>
/// Unity GameObject와 DB GameObject 비교하여 변경사항 감지 (GUID 기반)
/// </summary>
private GameObjectChangeSet DetectChanges(List<GameObject> unityObjects, Dictionary<string, DbGameObject> dbObjects)
{
var changeSet = new GameObjectChangeSet
{
Updated = new List<GameObject>(),
Inserted = new List<GameObject>(),
Deleted = new List<int>()
};
// Unity에 있는 객체 확인 (GUID 기반)
var processedGuids = new HashSet<string>();
foreach (var obj in unityObjects)
{
// GameObjectGuid 컴포넌트 확보
var guidComp = EnsureGameObjectGuid(obj);
string guid = guidComp.Guid;
processedGuids.Add(guid);
if (dbObjects.TryGetValue(guid, out var dbObj))
{
// DB에 존재: 변경 여부 확인
if (HasChanged(obj, dbObj))
{
changeSet.Updated.Add(obj);
}
}
else
{
// DB에 없음: 새로운 객체
changeSet.Inserted.Add(obj);
}
}
// DB에만 있고 Unity에 없는 객체 확인 (삭제된 객체)
foreach (var kvp in dbObjects)
{
if (!processedGuids.Contains(kvp.Key))
{
changeSet.Deleted.Add(kvp.Value.object_id);
}
}
return changeSet;
}
/// <summary>
/// GameObject가 DB 레코드와 비교하여 변경되었는지 확인
/// </summary>
private bool HasChanged(GameObject obj, DbGameObject dbObj)
{
// 이름 변경
if (obj.name != dbObj.object_name)
return true;
// Parent 변경
int? currentParentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
if (currentParentId != dbObj.parent_id)
return true;
// Tag 변경
if (obj.tag != dbObj.tag)
return true;
// Layer 변경
if (obj.layer != dbObj.layer)
return true;
// Active 상태 변경
if (obj.activeSelf != dbObj.is_active)
return true;
// Static 플래그 변경
if (obj.isStatic != dbObj.is_static)
return true;
return false;
}
#endregion
#region Batch Insert/Delete (Phase 2)
/// <summary>
/// GameObject 배치 삽입
/// </summary>
private async UniTask BatchInsertGameObjectsAsync(Scene scene, List<GameObject> gameObjects, CancellationToken cancellationToken)
{
if (gameObjects == null || gameObjects.Count == 0)
return;
await UniTask.SwitchToThreadPool();
try
{
var connection = databaseManager.Connector?.Connection;
if (connection == null)
{
ToolkitLogger.LogError("SyncManager", "Database connection is null");
return;
}
// 1. scene_id 가져오기 (또는 생성)
int sceneId = EnsureSceneRecord(connection, scene);
// 2. 배치 INSERT
for (int i = 0; i < gameObjects.Count; i += BatchSize)
{
int batchCount = Math.Min(BatchSize, gameObjects.Count - i);
var batch = gameObjects.GetRange(i, batchCount);
ExecuteInTransaction(connection, () =>
{
foreach (var obj in batch)
{
var guidComp = EnsureGameObjectGuid(obj);
string guid = guidComp.Guid;
int instanceId = obj.GetInstanceID();
int? parentId = obj.transform.parent != null ? obj.transform.parent.gameObject.GetInstanceID() : (int?)null;
var sql = @"
INSERT INTO gameobjects (guid, instance_id, scene_id, object_name, parent_id, tag, layer, is_active, is_static, is_deleted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, datetime('now', 'localtime'), datetime('now', 'localtime'))
";
connection.Execute(sql, guid, instanceId, sceneId, obj.name, parentId, obj.tag, obj.layer, obj.activeSelf, obj.isStatic);
}
});
ToolkitLogger.Log("SyncManager", $" 배치 삽입 완료: {batchCount}개 GameObject");
cancellationToken.ThrowIfCancellationRequested();
}
}
finally
{
// 메인 스레드가 살아있는지 확인
if (!isDisposed)
{
await UniTask.SwitchToMainThread();
}
}
}
/// <summary>
/// GameObject 배치 삭제 (soft delete)
/// </summary>
private async UniTask BatchMarkDeletedAsync(List<int> objectIds, CancellationToken cancellationToken)
{
if (objectIds == null || objectIds.Count == 0)
return;
await UniTask.SwitchToThreadPool();
try
{
var connection = databaseManager.Connector?.Connection;
if (connection == null)
{
ToolkitLogger.LogError("SyncManager", "Database connection is null");
return;
}
// 배치 UPDATE (soft delete)
for (int i = 0; i < objectIds.Count; i += BatchSize)
{
int batchCount = Math.Min(BatchSize, objectIds.Count - i);
var batch = objectIds.GetRange(i, batchCount);
ExecuteInTransaction(connection, () =>
{
foreach (var objectId in batch)
{
var sql = @"
UPDATE gameobjects
SET is_deleted = 1, updated_at = datetime('now', 'localtime')
WHERE object_id = ?
";
connection.Execute(sql, objectId);
}
});
ToolkitLogger.Log("SyncManager", $" 배치 삭제 완료: {batchCount}개 GameObject");
cancellationToken.ThrowIfCancellationRequested();
}
}
finally
{
// 메인 스레드가 살아있는지 확인
if (!isDisposed)
{
await UniTask.SwitchToMainThread();
}
}
}
/// <summary>
/// Scene 레코드가 DB에 존재하는지 확인하고 없으면 생성
/// </summary>
private int EnsureSceneRecord(SQLite.SQLiteConnection connection, Scene scene)
{
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
if (sceneIds.Any())
{
return sceneIds.First().scene_id;
}
// Scene 레코드 생성
var insertSql = @"
INSERT INTO scenes (scene_path, scene_name, created_at, updated_at)
VALUES (?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
";
connection.Execute(insertSql, scene.path, scene.name);
// 생성된 scene_id 반환
var newSceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
return newSceneIds.First().scene_id;
}
#endregion
#region Health Check
/// <summary>
/// SyncManager 상태 정보
/// </summary>
public SyncHealthStatus GetHealthStatus()
{
return new SyncHealthStatus
{
IsRunning = isRunning,
LastSyncTime = LastSyncTime,
SuccessfulSyncCount = SuccessfulSyncCount,
FailedSyncCount = FailedSyncCount,
SyncIntervalMs = SyncIntervalMilliseconds,
BatchSize = BatchSize
};
}
#endregion
#region Disposal
public void Dispose()
{
if (isDisposed)
{
return;
}
StopSync();
isDisposed = true;
ToolkitLogger.Log("SyncManager", "Disposed.");
}
private void ThrowIfDisposed()
{
if (isDisposed)
{
throw new ObjectDisposedException(nameof(SyncManager));
}
}
#endregion
}
#region Result Structs
/// <summary>
/// 동기화 결과
/// </summary>
public struct SyncResult
{
public bool Success;
public string Message;
public string ErrorMessage;
}
/// <summary>
/// SyncManager 상태
/// </summary>
public struct SyncHealthStatus
{
public bool IsRunning;
public DateTime LastSyncTime;
public int SuccessfulSyncCount;
public int FailedSyncCount;
public int SyncIntervalMs;
public int BatchSize;
public override string ToString()
{
return $"[SyncHealthStatus]\n" +
$" Running: {IsRunning}\n" +
$" Last Sync: {LastSyncTime:yyyy-MM-dd HH:mm:ss}\n" +
$" Success: {SuccessfulSyncCount}, Failed: {FailedSyncCount}\n" +
$" Interval: {SyncIntervalMs}ms, Batch: {BatchSize}";
}
}
/// <summary>
/// DB GameObject 레코드 (SQLite-net ORM용)
/// </summary>
public class DbGameObject
{
public int object_id { get; set; }
public int instance_id { get; set; }
public string guid { get; set; }
public string object_name { get; set; }
public int? parent_id { get; set; }
public string tag { get; set; }
public int layer { get; set; }
public bool is_active { get; set; }
public bool is_static { get; set; }
public bool is_deleted { get; set; }
}
/// <summary>
/// GameObject 변경사항 집합
/// </summary>
public class GameObjectChangeSet
{
public List<GameObject> Updated { get; set; }
public List<GameObject> Inserted { get; set; }
public List<int> Deleted { get; set; }
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 97a6b4ca15448704ab5758aa7642bca3

View File

@@ -0,0 +1,371 @@
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor
{
/// <summary>
/// Database 상태 및 컨트롤을 표시하는 별도 윈도우
/// </summary>
public class DatabaseStatusWindow : EditorWindow
{
#region Fields
private EditorServerWindow parentWindow;
// Data binding source
private EditorServerWindowData windowData = new EditorServerWindowData();
// UI Elements - Status
private VisualElement dbStatusIndicator;
private Label dbStatusLabel;
private Label dbFileExistsLabel;
private Label dbSyncStatusLabel;
// UI Elements - Buttons
private Button dbTestButton;
private Button dbConnectButton;
private Button dbDisconnectButton;
private Button dbMigrateButton;
private Button dbSyncToggleButton;
// UI Elements - Command History
private Label dbUndoCount;
private Label dbRedoCount;
private Button dbUndoButton;
private Button dbRedoButton;
private Button dbClearHistoryButton;
// UI Elements - Messages
private HelpBox dbErrorHelp;
private HelpBox dbSuccessHelp;
#endregion
#region Window Management
/// <summary>
/// 윈도우 열기 (팩토리 메서드)
/// </summary>
public static DatabaseStatusWindow Open(EditorServerWindow parentWindow)
{
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"Open 시작, parentWindow: {(parentWindow != null ? "" : "null")}");
var window = GetWindow<DatabaseStatusWindow>("Database Status & Controls");
window.minSize = new Vector2(400, 500);
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"GetWindow 완료, parentWindow 설정 전: {(window.parentWindow != null ? "" : "null")}");
window.parentWindow = parentWindow;
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"parentWindow 설정 완료: {(window.parentWindow != null ? "" : "null")}");
window.Show();
// CreateGUI()가 parentWindow 설정 전에 실행되었을 수 있으므로 다시 업데이트
ToolkitLogger.LogDebug("DatabaseStatusWindow", "Open에서 UpdateUI() 호출");
window.UpdateUI();
return window;
}
#endregion
#region Unity Lifecycle
private void CreateGUI()
{
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"CreateGUI 시작, parentWindow: {(parentWindow != null ? "" : "null")}");
// Load UXML
var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
"Packages/com.devgom.unity-editor-toolkit/Editor/DatabaseStatusWindow.uxml");
if (visualTree == null)
{
ToolkitLogger.LogError("DatabaseStatusWindow", "UXML file not found!");
return;
}
visualTree.CloneTree(rootVisualElement);
// Set data binding source
rootVisualElement.dataSource = windowData;
// Load USS (EditorServerWindow.uss 재사용)
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Packages/com.devgom.unity-editor-toolkit/Editor/EditorServerWindow.uss");
if (styleSheet != null)
{
rootVisualElement.styleSheets.Add(styleSheet);
}
// Query UI elements
QueryUIElements();
// Register events
RegisterEvents();
// Initial UI update
ToolkitLogger.LogDebug("DatabaseStatusWindow", "CreateGUI에서 UpdateUI() 호출");
UpdateUI();
}
private void OnDestroy()
{
// Cleanup
UnregisterEvents();
}
#endregion
#region UI Query
private void QueryUIElements()
{
var root = rootVisualElement;
// Status
dbStatusIndicator = root.Q<VisualElement>("db-status-indicator");
dbStatusLabel = root.Q<Label>("db-status-label");
dbFileExistsLabel = root.Q<Label>("db-file-exists-label");
dbSyncStatusLabel = root.Q<Label>("db-sync-status-label");
// Buttons
dbTestButton = root.Q<Button>("db-test-button");
dbConnectButton = root.Q<Button>("db-connect-button");
dbDisconnectButton = root.Q<Button>("db-disconnect-button");
dbMigrateButton = root.Q<Button>("db-migrate-button");
dbSyncToggleButton = root.Q<Button>("db-sync-toggle-button");
// Command History
dbUndoCount = root.Q<Label>("db-undo-count");
dbRedoCount = root.Q<Label>("db-redo-count");
dbUndoButton = root.Q<Button>("db-undo-button");
dbRedoButton = root.Q<Button>("db-redo-button");
dbClearHistoryButton = root.Q<Button>("db-clear-history-button");
// Messages
dbErrorHelp = root.Q<HelpBox>("db-error-help");
dbSuccessHelp = root.Q<HelpBox>("db-success-help");
}
#endregion
#region Event Handlers
private void RegisterEvents()
{
if (dbTestButton != null)
dbTestButton.clicked += OnTestConnectionClicked;
if (dbConnectButton != null)
dbConnectButton.clicked += OnConnectClicked;
if (dbDisconnectButton != null)
dbDisconnectButton.clicked += OnDisconnectClicked;
if (dbMigrateButton != null)
dbMigrateButton.clicked += OnRunMigrationsClicked;
if (dbSyncToggleButton != null)
dbSyncToggleButton.clicked += OnSyncToggleClicked;
if (dbUndoButton != null)
dbUndoButton.clicked += OnUndoClicked;
if (dbRedoButton != null)
dbRedoButton.clicked += OnRedoClicked;
if (dbClearHistoryButton != null)
dbClearHistoryButton.clicked += OnClearHistoryClicked;
}
private void UnregisterEvents()
{
if (dbTestButton != null)
dbTestButton.clicked -= OnTestConnectionClicked;
if (dbConnectButton != null)
dbConnectButton.clicked -= OnConnectClicked;
if (dbDisconnectButton != null)
dbDisconnectButton.clicked -= OnDisconnectClicked;
if (dbMigrateButton != null)
dbMigrateButton.clicked -= OnRunMigrationsClicked;
if (dbSyncToggleButton != null)
dbSyncToggleButton.clicked -= OnSyncToggleClicked;
if (dbUndoButton != null)
dbUndoButton.clicked -= OnUndoClicked;
if (dbRedoButton != null)
dbRedoButton.clicked -= OnRedoClicked;
if (dbClearHistoryButton != null)
dbClearHistoryButton.clicked -= OnClearHistoryClicked;
}
private void OnTestConnectionClicked()
{
parentWindow?.TestConnection();
UpdateUI();
}
private void OnConnectClicked()
{
parentWindow?.Connect();
UpdateUI();
}
private void OnDisconnectClicked()
{
parentWindow?.Disconnect();
UpdateUI();
}
private void OnRunMigrationsClicked()
{
parentWindow?.RunMigrations();
UpdateUI();
}
private void OnSyncToggleClicked()
{
parentWindow?.ToggleSync();
UpdateUI();
}
private void OnUndoClicked()
{
parentWindow?.Undo();
UpdateUI();
}
private void OnRedoClicked()
{
parentWindow?.Redo();
UpdateUI();
}
private void OnClearHistoryClicked()
{
parentWindow?.ClearHistory();
UpdateUI();
}
#endregion
#region UI Update
/// <summary>
/// UI 상태 업데이트
/// </summary>
public void UpdateUI()
{
ToolkitLogger.LogDebug("DatabaseStatusWindow", $"UpdateUI 시작, parentWindow: {(parentWindow != null ? "" : "null")}");
if (parentWindow == null)
{
ToolkitLogger.LogWarning("DatabaseStatusWindow", "parentWindow가 null이므로 업데이트 중단");
return;
}
// Status
ToolkitLogger.LogDebug("DatabaseStatusWindow", "상태 업데이트 시작");
UpdateConnectionStatus();
UpdateDatabaseFileStatus();
UpdateSyncStatus();
UpdateCommandHistory();
UpdateMessages();
ToolkitLogger.LogDebug("DatabaseStatusWindow", "상태 업데이트 완료");
}
private void UpdateConnectionStatus()
{
bool isConnected = parentWindow.IsConnected;
// Update data (UI auto-updates via data binding)
windowData.DbIsConnected = isConnected;
// Update status indicator classes (CSS classes cannot be bound)
if (dbStatusIndicator != null)
{
dbStatusIndicator.RemoveFromClassList("status-stopped");
dbStatusIndicator.RemoveFromClassList("status-running");
dbStatusIndicator.AddToClassList(isConnected ? "status-running" : "status-stopped");
}
// Update button states (not bound to data)
dbConnectButton?.SetEnabled(!isConnected);
dbDisconnectButton?.SetEnabled(isConnected);
if (dbDisconnectButton != null)
{
dbDisconnectButton.style.display = isConnected ? DisplayStyle.Flex : DisplayStyle.None;
}
}
private void UpdateDatabaseFileStatus()
{
bool fileExists = parentWindow.DatabaseFileExists();
// Update data (UI auto-updates via data binding)
windowData.DbFileExists = fileExists;
}
private void UpdateSyncStatus()
{
bool isSyncing = parentWindow.IsSyncing;
// Update data (UI auto-updates via data binding)
windowData.DbIsSyncing = isSyncing;
// Update button text (not bound to data)
if (dbSyncToggleButton != null)
{
dbSyncToggleButton.text = isSyncing ? "⏹️ Stop Sync" : "🔄 Start Sync";
}
}
private void UpdateCommandHistory()
{
int undoCount = parentWindow.UndoCount;
int redoCount = parentWindow.RedoCount;
// Update data (UI auto-updates via data binding)
windowData.DbUndoCount = undoCount;
windowData.DbRedoCount = redoCount;
// Update button states (not bound to data)
dbUndoButton?.SetEnabled(undoCount > 0);
dbRedoButton?.SetEnabled(redoCount > 0);
}
private void UpdateMessages()
{
var errorMessage = parentWindow.GetErrorMessage();
var successMessage = parentWindow.GetSuccessMessage();
if (dbErrorHelp != null)
{
if (!string.IsNullOrEmpty(errorMessage))
{
dbErrorHelp.text = errorMessage;
dbErrorHelp.RemoveFromClassList("hidden");
}
else
{
dbErrorHelp.AddToClassList("hidden");
}
}
if (dbSuccessHelp != null)
{
if (!string.IsNullOrEmpty(successMessage))
{
dbSuccessHelp.text = successMessage;
dbSuccessHelp.RemoveFromClassList("hidden");
}
else
{
dbSuccessHelp.AddToClassList("hidden");
}
}
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f7ccf14f7c29c064895231c4f68a2b52

View File

@@ -0,0 +1,81 @@
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="True">
<ui:ScrollView mode="Vertical" class="scroll-container">
<ui:VisualElement name="root" class="root-container">
<!-- Connection Status -->
<ui:Label text="📊 Status" class="section-title" />
<ui:VisualElement class="status-row">
<ui:Label text="🔌 Connection:" class="label-key" tooltip="SQLite 데이터베이스 연결 상태&#10;Connect 버튼으로 연결" />
<ui:VisualElement class="status-indicator-container">
<ui:VisualElement name="db-status-indicator" class="status-indicator status-stopped" />
<ui:Label name="db-status-label" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbStatusText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="💾 Database File:" class="label-key" tooltip="데이터베이스 파일 생성 여부&#10;Reinstall Database 버튼으로 생성" />
<ui:Label name="db-file-exists-label" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbFileExistsText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="🔄 Sync Status:" class="label-key" tooltip="실시간 GameObject/Component 동기화 상태&#10;(Phase 2에서 구현 예정)" />
<ui:Label name="db-sync-status-label" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbSyncStatusText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<!-- Action Buttons -->
<ui:Label text="🎮 Connection Controls" class="section-title" style="margin-top: 15px;" />
<ui:VisualElement class="button-row">
<ui:Button name="db-test-button" text="🔍 Test Connection" class="action-button" tooltip="데이터베이스 연결 테스트&#10;SQLite 버전 정보 확인" />
<ui:Button name="db-connect-button" text="🔌 Connect" class="action-button" tooltip="데이터베이스에 연결&#10;Command History 활성화" />
<ui:Button name="db-disconnect-button" text="⏸️ Disconnect" class="action-button hidden" tooltip="데이터베이스 연결 해제" />
</ui:VisualElement>
<ui:VisualElement class="button-row">
<ui:Button name="db-migrate-button" text="⚙️ Run Migrations" class="primary-button" tooltip="스키마 마이그레이션 실행&#10;테이블/인덱스/트리거 생성" />
<ui:Button name="db-sync-toggle-button" text="🔄 Start Sync" class="secondary-button" tooltip="GameObject/Component 실시간 동기화&#10;(Phase 2에서 구현 예정)" />
</ui:VisualElement>
<!-- Command History (Undo/Redo) -->
<ui:Label text="📜 Command History" class="section-title" style="margin-top: 15px;" />
<ui:VisualElement class="status-row">
<ui:Label text="⟲ Undo Stack:" class="label-key" tooltip="실행 취소 가능한 명령 수&#10;데이터베이스에 영구 저장" />
<ui:Label name="db-undo-count" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbUndoCountText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="⟳ Redo Stack:" class="label-key" tooltip="다시 실행 가능한 명령 수&#10;Undo 후 사용 가능" />
<ui:Label name="db-redo-count" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbRedoCountText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="button-row">
<ui:Button name="db-undo-button" text="⟲ Undo" class="action-button" tooltip="마지막 명령 실행 취소&#10;GameObject/Component 변경 복원" />
<ui:Button name="db-redo-button" text="⟳ Redo" class="action-button" tooltip="취소한 명령 다시 실행" />
<ui:Button name="db-clear-history-button" text="🗑️ Clear History" class="action-button" tooltip="모든 명령 히스토리 삭제&#10;Undo/Redo 불가능" />
</ui:VisualElement>
<!-- Status Messages -->
<ui:HelpBox name="db-error-help" text="" message-type="Error" class="hidden" />
<ui:HelpBox name="db-success-help" text="" message-type="Info" class="hidden" />
</ui:VisualElement>
</ui:ScrollView>
</ui:UXML>

View File

@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: d457029b6b1b58d47bb7aa38a3897205
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@@ -0,0 +1,542 @@
using UnityEngine;
using UnityEditor;
using System;
using System.IO;
using System.Diagnostics;
using System.Text;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor
{
/// <summary>
/// Handles CLI script installation and version management
/// </summary>
public class EditorServerCLIInstaller
{
private const int LockFileStaleMinutes = 10;
private const int NpmInstallTimeoutSeconds = 30;
private const int NpmBuildTimeoutSeconds = 120;
private const int NpmInstallExtendedTimeoutSeconds = 300; // 5 minutes for initial install
private const int TimestampFutureToleranceMinutes = 1;
private const long DefaultRequiredDiskSpaceMB = 500;
private const int LockAcquisitionRetryIntervalMs = 500;
public string PluginVersion { get; private set; }
public string LocalCLIVersion { get; private set; }
public string HomeCLIVersion { get; private set; }
public bool UpdateAvailable { get; private set; }
public bool IsInstalling { get; private set; }
public string InstallLog { get; private set; } = "";
private string pluginScriptsPathOverride;
public EditorServerCLIInstaller(string pathOverride = null)
{
pluginScriptsPathOverride = pathOverride;
}
/// <summary>
/// Check CLI versions and update availability
/// </summary>
public void CheckVersion()
{
PluginVersion = EditorServerPathManager.GetPluginVersion();
LocalCLIVersion = EditorServerPathManager.GetLocalCLIVersion();
HomeCLIVersion = EditorServerPathManager.GetHomeCLIVersion();
// Update available when home CLI version differs from installed local CLI version
UpdateAvailable = (HomeCLIVersion != null && LocalCLIVersion != null && HomeCLIVersion != LocalCLIVersion);
}
/// <summary>
/// Check if minor version difference (same major.minor, different patch)
/// </summary>
public static bool IsMinorVersionDifference(string v1, string v2)
{
try
{
var parts1 = v1.Split('.');
var parts2 = v2.Split('.');
if (parts1.Length >= 2 && parts2.Length >= 2)
{
// Same major and minor version? (only patch different)
return parts1[0] == parts2[0] && parts1[1] == parts2[1];
}
}
catch (Exception)
{
return false;
}
return false;
}
/// <summary>
/// Check if installation is in progress
/// </summary>
public bool IsInstallationInProgress()
{
string projectRoot = Path.GetDirectoryName(Application.dataPath);
string lockFile = Path.Combine(projectRoot, ".unity-websocket", ".install.lock");
if (!File.Exists(lockFile))
{
return false;
}
// Check if lock is stale
if (IsLockStale(lockFile))
{
ToolkitLogger.LogWarning("CLIInstaller", "Removing stale installation lock");
try
{
File.Delete(lockFile);
}
catch (Exception e)
{
ToolkitLogger.LogError("CLIInstaller", $"Failed to delete stale lock: {e.Message}");
}
return false;
}
return true;
}
/// <summary>
/// Clear installation lock file
/// </summary>
public void ClearInstallationLock()
{
string projectRoot = Path.GetDirectoryName(Application.dataPath);
string lockFile = Path.Combine(projectRoot, ".unity-websocket", ".install.lock");
if (File.Exists(lockFile))
{
try
{
File.Delete(lockFile);
ToolkitLogger.Log("CLIInstaller", "Installation lock cleared");
EditorUtility.DisplayDialog("Lock Cleared", "Installation lock has been cleared.\nYou can now retry installation.", "OK");
}
catch (Exception e)
{
ToolkitLogger.LogError("CLIInstaller", $"Failed to clear lock: {e.Message}");
EditorUtility.DisplayDialog("Error", $"Failed to clear lock:\n{e.Message}", "OK");
}
}
}
/// <summary>
/// Install or update CLI scripts
/// </summary>
public void InstallOrUpdate()
{
IsInstalling = true;
InstallLog = "";
string projectRoot = Path.GetDirectoryName(Application.dataPath);
string outputDir = Path.Combine(projectRoot, ".unity-websocket");
string lockFile = Path.Combine(outputDir, ".install.lock");
try
{
// Pre-flight checks
InstallLog += "[Pre-flight] Running system checks...\n";
// Check disk space
if (!CheckDiskSpace(projectRoot, DefaultRequiredDiskSpaceMB))
{
bool proceed = EditorUtility.DisplayDialog("Low Disk Space",
$"Less than {DefaultRequiredDiskSpaceMB}MB available. Installation may fail.\n\nProceed anyway?",
"Yes", "No");
if (!proceed)
{
InstallLog += "❌ Installation cancelled by user (low disk space)\n";
return;
}
}
// Check write permission
if (!CheckWritePermission(outputDir))
{
EditorUtility.DisplayDialog("Permission Denied",
$"Cannot write to {outputDir}\n\n" +
"If using version control:\n" +
"• Check out the .unity-websocket folder\n" +
"• Or add it to .gitignore/.p4ignore",
"OK");
InstallLog += "❌ Write permission denied\n";
return;
}
// Acquire installation lock
InstallLog += "[Pre-flight] Acquiring installation lock...\n";
string lockError;
if (!AcquireLock(lockFile, out lockError))
{
EditorUtility.DisplayDialog("Installation In Progress", lockError ?? "Cannot acquire lock", "OK");
InstallLog += $"❌ {lockError}\n";
return;
}
InstallLog += "✓ Pre-flight checks passed\n\n";
string skillsDir = Path.Combine(outputDir, "skills", "scripts");
// Step 1: Create output directory
InstallLog += "[1/5] Creating output directory...\n";
if (!Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}
// Create .gitignore
string gitignorePath = Path.Combine(outputDir, ".gitignore");
if (!File.Exists(gitignorePath))
{
File.WriteAllText(gitignorePath, "# Unity WebSocket generated files\n*\n!.gitignore\n");
}
// Step 2: Remove old CLI scripts
InstallLog += "[2/5] Removing old CLI scripts...\n";
if (Directory.Exists(skillsDir))
{
Directory.Delete(skillsDir, true);
}
// Step 3: Copy CLI scripts from plugin
InstallLog += "[3/5] Copying CLI scripts...\n";
string pluginScriptsPath = EditorServerPathManager.FindPluginScriptsPath(pluginScriptsPathOverride);
if (string.IsNullOrEmpty(pluginScriptsPath))
{
InstallLog += "❌ ERROR: Plugin scripts not found!\n";
ToolkitLogger.LogError("CLIInstaller", "Plugin scripts path not found");
return;
}
EditorServerPathManager.CopyDirectory(pluginScriptsPath, skillsDir);
InstallLog += $"✓ Copied from: {pluginScriptsPath}\n";
// Step 4: npm install
InstallLog += "[4/5] Installing dependencies (npm install)...\n";
InstallLog += "This may take a minute...\n";
string npmOutput = EditorServerCommandRunner.RunCommand("npm", "install", skillsDir, NpmInstallExtendedTimeoutSeconds);
InstallLog += "✓ Dependencies installed\n";
// Step 5: npm run build
InstallLog += "[5/5] Building CLI (npm run build)...\n";
string buildOutput = EditorServerCommandRunner.RunCommand("npm", "run build", skillsDir, NpmBuildTimeoutSeconds);
InstallLog += "✓ Build completed\n";
// Create CLI wrapper
CreateCLIWrapper(outputDir, skillsDir);
InstallLog += "\n✅ CLI installation completed successfully!\n";
ToolkitLogger.Log("CLIInstaller", "CLI scripts installed successfully");
// Refresh version info
CheckVersion();
}
catch (Exception e)
{
InstallLog += $"\n❌ ERROR: {e.Message}\n";
// Check for common errors and provide hints
if (e.Message.Contains("ENOSPC"))
{
InstallLog += "\n💡 Hint: Disk space full. Free up space and try again.\n";
}
else if (e.Message.Contains("EACCES") || e.Message.Contains("permission"))
{
InstallLog += "\n💡 Hint: Permission denied. Check folder permissions or run as administrator.\n";
}
else if (e.Message.Contains("ETIMEDOUT") || e.Message.Contains("network"))
{
InstallLog += "\n💡 Hint: Network timeout. Check your internet connection.\n";
InstallLog += " If behind a proxy, configure npm:\n";
InstallLog += " npm config set proxy http://proxy.company.com:8080\n";
}
ToolkitLogger.LogError("CLIInstaller", $"CLI installation failed: {e.Message}");
}
finally
{
ReleaseLock(lockFile);
IsInstalling = false;
}
}
private bool IsLockStale(string lockPath)
{
try
{
string[] lines = File.ReadAllLines(lockPath);
if (lines.Length < 2) return true;
// Check if process is running
if (int.TryParse(lines[0], out int pid))
{
if (pid <= 0) return true;
int currentPID = Process.GetCurrentProcess().Id;
if (pid == currentPID) return false;
try
{
Process process = Process.GetProcessById(pid);
if (process.HasExited) return true;
string processName = process.ProcessName.ToLower();
bool isUnityEditor = processName.Contains("unity") && !processName.Contains("unityhub");
if (isUnityEditor) return false;
return true;
}
catch (ArgumentException) { return true; }
catch (InvalidOperationException) { return true; }
}
// Fallback to timestamp
if (DateTime.TryParse(lines[1], out DateTime lockTimestamp))
{
if (lockTimestamp > DateTime.Now.AddMinutes(TimestampFutureToleranceMinutes)) return true;
if ((DateTime.Now - lockTimestamp).TotalMinutes > LockFileStaleMinutes) return true;
}
else
{
return true;
}
return false;
}
catch (Exception)
{
return true;
}
}
private bool AcquireLock(string lockPath, out string errorMessage)
{
errorMessage = null;
DateTime startTime = DateTime.Now;
int timeoutSeconds = NpmInstallTimeoutSeconds;
while ((DateTime.Now - startTime).TotalSeconds < timeoutSeconds)
{
try
{
// Try to open or create the lock file atomically
using (FileStream fs = File.Open(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
// Check if file has content (existing lock)
if (fs.Length > 0)
{
// Read existing lock content
byte[] buffer = new byte[fs.Length];
fs.Read(buffer, 0, (int)fs.Length);
string existingContent = Encoding.UTF8.GetString(buffer);
string[] lines = existingContent.Split('\n');
// Check if lock is stale
bool isStale = false;
if (lines.Length >= 2)
{
if (int.TryParse(lines[0], out int pid))
{
int currentPID = Process.GetCurrentProcess().Id;
if (pid == currentPID)
{
// Same process, reuse lock
return true;
}
try
{
Process process = Process.GetProcessById(pid);
if (process.HasExited)
{
isStale = true;
}
else
{
string processName = process.ProcessName.ToLower();
bool isUnityEditor = processName.Contains("unity") && !processName.Contains("unityhub");
if (!isUnityEditor)
{
isStale = true;
}
}
}
catch (ArgumentException) { isStale = true; }
catch (InvalidOperationException) { isStale = true; }
}
// Check timestamp as fallback
if (!isStale && DateTime.TryParse(lines[1], out DateTime lockTimestamp))
{
if (lockTimestamp > DateTime.Now.AddMinutes(TimestampFutureToleranceMinutes) ||
(DateTime.Now - lockTimestamp).TotalMinutes > LockFileStaleMinutes)
{
isStale = true;
}
}
}
else
{
isStale = true; // Invalid format
}
if (!isStale)
{
// Lock is valid and owned by another process
fs.Close();
System.Threading.Thread.Sleep(LockAcquisitionRetryIntervalMs);
continue;
}
// Lock is stale, overwrite it
fs.SetLength(0);
fs.Seek(0, SeekOrigin.Begin);
}
// Write new lock content
int currentPID2 = Process.GetCurrentProcess().Id;
string lockContent = $"{currentPID2}\n{DateTime.Now:yyyy-MM-dd HH:mm:ss}";
byte[] info = Encoding.UTF8.GetBytes(lockContent);
fs.Write(info, 0, info.Length);
fs.Flush();
return true;
}
}
catch (IOException)
{
// File is locked by another process
System.Threading.Thread.Sleep(LockAcquisitionRetryIntervalMs);
}
catch (Exception e)
{
errorMessage = $"Lock acquisition failed: {e.Message}";
return false;
}
}
errorMessage = "Another Unity instance is installing CLI scripts. Please wait and try again.";
return false;
}
private void ReleaseLock(string lockPath)
{
try
{
if (File.Exists(lockPath))
{
File.Delete(lockPath);
}
}
catch (Exception e)
{
ToolkitLogger.LogWarning("CLIInstaller", $"Failed to release lock: {e.Message}");
}
}
private bool CheckDiskSpace(string path, long requiredMB = 500)
{
try
{
string rootPath = Path.GetPathRoot(path);
if (string.IsNullOrEmpty(rootPath))
{
ToolkitLogger.LogWarning("CLIInstaller", "Could not determine drive root path");
return true; // Cannot check, assume OK
}
DriveInfo drive = new DriveInfo(rootPath);
// Check if drive is ready (mounted and accessible)
if (!drive.IsReady)
{
ToolkitLogger.LogWarning("CLIInstaller", $"Drive {rootPath} is not ready");
return true; // Cannot check, assume OK
}
long availableMB = drive.AvailableFreeSpace / (1024 * 1024);
InstallLog += $"💾 Disk space: {availableMB}MB available on {rootPath}\n";
if (availableMB < requiredMB)
{
InstallLog += $"⚠️ Low disk space: {requiredMB}MB recommended\n";
return false;
}
return true;
}
catch (ArgumentException e)
{
ToolkitLogger.LogWarning("CLIInstaller", $"Invalid drive path: {e.Message}");
return true; // Cannot check, assume OK
}
catch (Exception e)
{
ToolkitLogger.LogWarning("CLIInstaller", $"Could not check disk space: {e.Message}");
return true; // Cannot check, assume OK
}
}
private bool CheckWritePermission(string directory)
{
try
{
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
string testFile = Path.Combine(directory, ".writetest");
File.WriteAllText(testFile, "test");
File.Delete(testFile);
return true;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (Exception e)
{
ToolkitLogger.LogWarning("CLIInstaller", $"Write permission check failed: {e.Message}");
return true;
}
}
private void CreateCLIWrapper(string outputDir, string skillsDir)
{
string wrapperPath = Path.Combine(outputDir, "uw.js");
string wrapperContent = @"#!/usr/bin/env node
/**
* Unity WebSocket CLI Wrapper
*
* This wrapper script forwards all arguments to the local CLI installation.
* Auto-generated by Unity Editor Toolkit.
*
* Usage: node .unity-websocket/uw.js <command> [options]
* Example: node .unity-websocket/uw.js hierarchy
*/
const path = require('path');
// Set CLAUDE_PROJECT_DIR to project root (parent of .unity-websocket)
process.env.CLAUDE_PROJECT_DIR = path.resolve(__dirname, '..');
// Get the actual CLI path
const cliPath = path.join(__dirname, 'skills', 'scripts', 'dist', 'cli', 'cli.js');
// Forward to the actual CLI
require(cliPath);
";
File.WriteAllText(wrapperPath, wrapperContent);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 50e367fb4a28f304889d8a82ec665e30

View File

@@ -0,0 +1,213 @@
using UnityEngine;
using System;
using System.Diagnostics;
using System.Text;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor
{
/// <summary>
/// Handles external command execution (Node.js, npm, etc.)
/// </summary>
public static class EditorServerCommandRunner
{
private const int ProcessKillWaitTimeoutMs = 5000; // 5 seconds
private const int DefaultCommandTimeoutSeconds = 120; // 2 minutes
/// <summary>
/// Check if Node.js is installed and accessible
/// </summary>
public static bool CheckNodeInstallation()
{
return !string.IsNullOrEmpty(GetNodeVersion());
}
/// <summary>
/// Get Node.js version string (e.g., "v22.17.0")
/// </summary>
public static string GetNodeVersion()
{
try
{
string nodeCommand = Application.platform == RuntimePlatform.WindowsEditor ? "node.exe" : "node";
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = nodeCommand,
Arguments = "--version",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using (Process process = Process.Start(startInfo))
{
process.WaitForExit(ProcessKillWaitTimeoutMs);
if (process.ExitCode == 0)
{
return process.StandardOutput.ReadToEnd().Trim();
}
}
}
catch (Exception)
{
return null;
}
return null;
}
/// <summary>
/// Run a command with arguments in a specific working directory
/// </summary>
public static string RunCommand(string command, string arguments, string workingDirectory, int timeoutSeconds = DefaultCommandTimeoutSeconds)
{
ProcessStartInfo startInfo = new ProcessStartInfo();
// Platform-specific command execution
// On Windows, .cmd files must be executed through cmd.exe when UseShellExecute = false
if (Application.platform == RuntimePlatform.WindowsEditor && command == "npm")
{
startInfo.FileName = "cmd.exe";
startInfo.Arguments = $"/c npm {arguments}";
}
else
{
startInfo.FileName = command;
startInfo.Arguments = arguments;
}
startInfo.WorkingDirectory = workingDirectory;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.StandardOutputEncoding = Encoding.UTF8;
startInfo.StandardErrorEncoding = Encoding.UTF8;
// Explicitly copy environment variables to ensure npm can find its dependencies
// When UseShellExecute = false, environment variables are not automatically inherited
// This fixes issues where npm.cmd cannot find node.exe or its internal modules
foreach (System.Collections.DictionaryEntry envVar in Environment.GetEnvironmentVariables())
{
try
{
string key = envVar.Key.ToString();
string value = envVar.Value.ToString();
// Add or update environment variable (case-insensitive on Windows)
if (startInfo.EnvironmentVariables.ContainsKey(key))
{
startInfo.EnvironmentVariables[key] = value;
}
else
{
startInfo.EnvironmentVariables.Add(key, value);
}
}
catch (Exception)
{
// Skip problematic environment variables (e.g., null values)
continue;
}
}
Process process = null;
StringBuilder outputBuilder = new StringBuilder();
StringBuilder errorBuilder = new StringBuilder();
try
{
process = Process.Start(startInfo);
if (process == null)
{
throw new Exception($"Failed to start process: {command}");
}
// Read output asynchronously to prevent deadlocks
process.OutputDataReceived += (sender, e) => {
if (e.Data != null)
{
lock (outputBuilder)
{
outputBuilder.AppendLine(e.Data);
}
}
};
process.ErrorDataReceived += (sender, e) => {
if (e.Data != null)
{
lock (errorBuilder)
{
errorBuilder.AppendLine(e.Data);
}
}
};
process.BeginOutputReadLine();
process.BeginErrorReadLine();
bool exited = process.WaitForExit(timeoutSeconds * 1000);
if (!exited)
{
ToolkitLogger.LogWarning("CommandRunner", $"Process timeout after {timeoutSeconds}s, killing: {command} {arguments}");
try
{
// Kill the process tree (including child processes)
if (!process.HasExited)
{
process.Kill();
bool killed = process.WaitForExit(ProcessKillWaitTimeoutMs);
if (!killed)
{
ToolkitLogger.LogError("CommandRunner", "Process did not terminate after kill signal");
}
}
}
catch (Exception killEx)
{
ToolkitLogger.LogError("CommandRunner", $"Failed to kill process: {killEx.Message}");
}
throw new Exception($"{command} timed out after {timeoutSeconds} seconds. Check network connection or increase timeout.");
}
// Wait for async output reading to complete
process.WaitForExit();
string output = outputBuilder.ToString();
string error = errorBuilder.ToString();
if (process.ExitCode != 0)
{
throw new Exception($"{command} {arguments} failed (exit code {process.ExitCode}):\n{error}");
}
return output;
}
finally
{
// Always cleanup process resources
if (process != null)
{
try
{
if (!process.HasExited)
{
process.Kill();
process.WaitForExit(ProcessKillWaitTimeoutMs);
}
process.Dispose();
}
catch (Exception ex)
{
ToolkitLogger.LogError("CommandRunner", $"Error disposing process: {ex.Message}");
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e680fb7f17ff34747a963b640f3eee23

View File

@@ -0,0 +1,258 @@
using UnityEngine;
using System;
using System.IO;
using System.Text.RegularExpressions;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Editor
{
/// <summary>
/// Handles path management for plugin scripts and CLI installation
/// </summary>
public static class EditorServerPathManager
{
/// <summary>
/// Get the default plugin scripts path in user's home directory
/// </summary>
public static string GetDefaultPluginScriptsPath()
{
string homeFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(homeFolder, ".claude", "plugins", "marketplaces", "dev-gom-plugins",
"plugins", "unity-editor-toolkit", "skills", "scripts");
}
/// <summary>
/// Find the plugin scripts path (custom or default)
/// </summary>
public static string FindPluginScriptsPath(string customPath = null)
{
// Use custom path if set
if (!string.IsNullOrEmpty(customPath))
{
// Security: Validate path to prevent path traversal
string normalized = Path.GetFullPath(customPath);
string homeFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string allowedPath = Path.Combine(homeFolder, ".claude", "plugins");
if (!normalized.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
{
ToolkitLogger.LogError("PathManager", $"Plugin path outside allowed directory: {customPath}");
return null;
}
if (Directory.Exists(normalized) &&
File.Exists(Path.Combine(normalized, "package.json")))
{
return normalized;
}
}
// Use default home folder based path
string defaultPath = GetDefaultPluginScriptsPath();
if (Directory.Exists(defaultPath) &&
File.Exists(Path.Combine(defaultPath, "package.json")))
{
return defaultPath;
}
return null;
}
/// <summary>
/// Find package.json path in Unity Package
/// </summary>
public static string FindPackageJsonPath()
{
string projectRoot = Path.GetDirectoryName(Application.dataPath);
// Try to find the package.json in various locations
string[][] searchPathComponents = new string[][]
{
// Installed via Package Manager
new string[] { "Packages", "com.devgom.unity-editor-toolkit", "package.json" },
// Installed in Assets
new string[] { "Assets", "UnityEditorToolkit", "package.json" },
new string[] { "Assets", "Packages", "UnityEditorToolkit", "package.json" }
};
foreach (string[] components in searchPathComponents)
{
// Build path using Path.Combine for cross-platform compatibility
string[] fullPathComponents = new string[components.Length + 1];
fullPathComponents[0] = projectRoot;
Array.Copy(components, 0, fullPathComponents, 1, components.Length);
string fullPath = Path.Combine(fullPathComponents);
if (File.Exists(fullPath))
{
return fullPath;
}
}
return null;
}
/// <summary>
/// Extract version from package.json content
/// </summary>
public static string ExtractVersionFromJson(string json)
{
// Simple regex to extract version (avoiding full JSON parser)
Match match = Regex.Match(json, @"""version""\s*:\s*""([^""]+)""");
return match.Success ? match.Groups[1].Value : null;
}
/// <summary>
/// Get plugin version from package.json
/// </summary>
public static string GetPluginVersion()
{
try
{
// Find package.json in the Unity Package
string packagePath = FindPackageJsonPath();
if (string.IsNullOrEmpty(packagePath) || !File.Exists(packagePath))
{
ToolkitLogger.LogWarning("PathManager", "package.json not found");
return null;
}
string json = File.ReadAllText(packagePath);
return ExtractVersionFromJson(json);
}
catch (Exception e)
{
ToolkitLogger.LogError("PathManager", $"Unity Editor Toolkit: Failed to read plugin version: {e.Message}");
return null;
}
}
/// <summary>
/// Get local CLI version from installed scripts
/// </summary>
public static string GetLocalCLIVersion()
{
try
{
string projectRoot = Path.GetDirectoryName(Application.dataPath);
string localPackageJson = Path.Combine(projectRoot, ".unity-websocket", "skills", "scripts", "package.json");
if (!File.Exists(localPackageJson))
{
return null;
}
string json = File.ReadAllText(localPackageJson);
return ExtractVersionFromJson(json);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Get home folder CLI version (source version to be installed)
/// </summary>
public static string GetHomeCLIVersion()
{
try
{
string homeCLIPath = FindPluginScriptsPath();
if (string.IsNullOrEmpty(homeCLIPath))
{
return null;
}
string homePackageJson = Path.Combine(homeCLIPath, "package.json");
if (!File.Exists(homePackageJson))
{
return null;
}
string json = File.ReadAllText(homePackageJson);
return ExtractVersionFromJson(json);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Copy directory with security validation
/// </summary>
public static void CopyDirectory(string sourceDir, string destDir)
{
// Security: Validate and normalize paths to prevent path traversal
string normalizedSource = Path.GetFullPath(sourceDir);
string normalizedDest = Path.GetFullPath(destDir);
// Validate source is in allowed plugin directory
string homeFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
string allowedPluginPath = Path.Combine(homeFolder, ".claude", "plugins");
if (!normalizedSource.StartsWith(allowedPluginPath, StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException($"Source path outside allowed directory: {sourceDir}");
}
// Validate destination is within project
string projectRoot = Path.GetDirectoryName(Application.dataPath);
if (!normalizedDest.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException($"Destination path outside project: {destDir}");
}
// Check for symbolic links (security risk)
DirectoryInfo sourceInfo = new DirectoryInfo(normalizedSource);
if ((sourceInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
{
throw new UnauthorizedAccessException("Symbolic links are not allowed");
}
Directory.CreateDirectory(normalizedDest);
// Copy files with validation
foreach (string file in Directory.GetFiles(normalizedSource))
{
string fileName = Path.GetFileName(file);
// Validate filename (prevent null byte injection)
if (fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || fileName.Contains('\0'))
{
ToolkitLogger.LogWarning("PathManager", $"Skipping invalid file name: {fileName}");
continue;
}
string destFile = Path.Combine(normalizedDest, fileName);
// Validate final path stays within destination
string normalizedDestFile = Path.GetFullPath(destFile);
if (!normalizedDestFile.StartsWith(normalizedDest, StringComparison.OrdinalIgnoreCase))
{
ToolkitLogger.LogWarning("PathManager", $"Skipping file outside destination: {fileName}");
continue;
}
File.Copy(file, normalizedDestFile, true);
}
// Copy subdirectories recursively with validation
foreach (string dir in Directory.GetDirectories(normalizedSource))
{
string dirName = Path.GetFileName(dir);
// Skip node_modules, dist, hidden folders, and cache
if (dirName == "node_modules" || dirName == "dist" ||
dirName.StartsWith(".") || dirName == "__pycache__")
{
continue;
}
string destSubDir = Path.Combine(normalizedDest, dirName);
CopyDirectory(dir, destSubDir);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0e62f769f30c221488d0557814e98c52

View File

@@ -0,0 +1,654 @@
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
using UnityEditorToolkit.Editor.Server;
using UnityEditorToolkit.Editor.Attributes;
using UnityEditorToolkit.Editor.Database;
using UnityEditorToolkit.Editor.Utils;
using Cysharp.Threading.Tasks;
using System.IO;
using UnityEditor.Callbacks;
namespace UnityEditorToolkit.Editor
{
/// <summary>
/// Editor window for Unity Editor Toolkit Server (UI Toolkit version)
/// </summary>
public partial class EditorServerWindow : EditorWindow
{
private EditorWebSocketServer server => EditorWebSocketServer.Instance;
private EditorServerCLIInstaller cliInstaller;
// Data binding source
private EditorServerWindowData windowData = new EditorServerWindowData();
private bool wasPlaying = false;
private float lastUpdateTime = 0f;
private bool hasNodeJS = false;
private const string PREF_KEY_PLUGIN_PATH = "UnityEditorToolkit.PluginScriptsPath";
private const float UI_UPDATE_INTERVAL_SECONDS = 0.5f;
// UI Elements
private VisualElement statusIndicator;
private Label serverStatusLabel;
private Label serverPortLabel;
private Label connectedClientsLabel;
private Toggle autostartToggle;
private EnumField logLevelDropdown;
private Button startButton;
private Button stopButton;
private VisualElement nodejsMissingSection;
private VisualElement cliStatusSection;
private Label packageVersionLabel;
private Label cliVersionLabel;
// Status messages
private HelpBox installProgressHelp;
private HelpBox notInstalledHelp;
private HelpBox updateMinorHelp;
private HelpBox updateMajorHelp;
private HelpBox upToDateHelp;
// Buttons
private Button installButton;
private Button updateButton;
private Button reinstallButton;
private Button clearLockButton;
// Installation
private HelpBox installingMessage;
private VisualElement installLogContainer;
private TextField installLogField;
private TextField pluginPathField;
[MenuItem("Tools/Unity Editor Toolkit/Server Window")]
public static void ShowWindow()
{
var window = GetWindow<EditorServerWindow>("Editor Server");
window.minSize = new Vector2(400, 300);
window.Show();
}
private void OnEnable()
{
// Initialize CLI installer
string pathOverride = EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null);
cliInstaller = new EditorServerCLIInstaller(pathOverride);
// Check Node.js installation
hasNodeJS = EditorServerCommandRunner.CheckNodeInstallation();
// Check CLI version
if (hasNodeJS)
{
cliInstaller.CheckVersion();
}
}
private void OnServerStartedHandler()
{
// Auto-reconnect database if it was previously connected
if (EditorPrefs.GetBool("UnityEditorToolkit.Database.AutoReconnect", false))
{
EditorPrefs.DeleteKey("UnityEditorToolkit.Database.AutoReconnect");
// Reconnect using saved config
string dbPath = EditorPrefs.GetString("UnityEditorToolkit.Database.Path", "");
bool enableWAL = EditorPrefs.GetBool("UnityEditorToolkit.Database.EnableWAL", true);
if (!string.IsNullOrEmpty(dbPath))
{
var config = new DatabaseConfig
{
DatabaseFilePath = dbPath,
EnableWAL = enableWAL
};
DatabaseManager.Instance.InitializeAsync(config).Forget();
ToolkitLogger.Log("EditorServerWindow", "서버 시작 - 데이터베이스 자동 재연결");
}
}
UpdateUI();
}
private void OnServerStoppedHandler()
{
// Save auto-reconnect flag before disconnecting
if (DatabaseManager.Instance.IsConnected)
{
EditorPrefs.SetBool("UnityEditorToolkit.Database.AutoReconnect", true);
}
// Disconnect database when server stops
DatabaseManager.Instance.Disconnect();
UpdateUI();
}
public void CreateGUI()
{
// Load UXML
var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
"Packages/com.devgom.unity-editor-toolkit/Editor/EditorServerWindow.uxml");
if (visualTree == null)
{
var label = new Label("Error: Could not load EditorServerWindow.uxml");
label.style.color = Color.red;
rootVisualElement.Add(label);
return;
}
visualTree.CloneTree(rootVisualElement);
// Set data binding source
rootVisualElement.dataSource = windowData;
// Load USS
var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(
"Packages/com.devgom.unity-editor-toolkit/Editor/EditorServerWindow.uss");
if (styleSheet != null)
{
rootVisualElement.styleSheets.Add(styleSheet);
}
// Query all UI elements
QueryUIElements();
// Bind events
BindEvents();
// Initialize Database UI
InitializeDatabaseUI();
// Initial UI update
UpdateUI();
// Log initialization status (한 번에 출력)
LogInitializationStatus();
}
private void QueryUIElements()
{
// Server section
statusIndicator = rootVisualElement.Q<VisualElement>("status-indicator");
serverStatusLabel = rootVisualElement.Q<Label>("server-status");
serverPortLabel = rootVisualElement.Q<Label>("server-port");
connectedClientsLabel = rootVisualElement.Q<Label>("connected-clients");
autostartToggle = rootVisualElement.Q<Toggle>("autostart-toggle");
logLevelDropdown = rootVisualElement.Q<EnumField>("log-level-dropdown");
startButton = rootVisualElement.Q<Button>("start-button");
stopButton = rootVisualElement.Q<Button>("stop-button");
// CLI section
nodejsMissingSection = rootVisualElement.Q<VisualElement>("nodejs-missing");
cliStatusSection = rootVisualElement.Q<VisualElement>("cli-status");
packageVersionLabel = rootVisualElement.Q<Label>("package-version");
cliVersionLabel = rootVisualElement.Q<Label>("cli-version");
// Status messages
installProgressHelp = rootVisualElement.Q<HelpBox>("install-progress");
notInstalledHelp = rootVisualElement.Q<HelpBox>("not-installed");
updateMinorHelp = rootVisualElement.Q<HelpBox>("update-minor");
updateMajorHelp = rootVisualElement.Q<HelpBox>("update-major");
upToDateHelp = rootVisualElement.Q<HelpBox>("up-to-date");
// Buttons
installButton = rootVisualElement.Q<Button>("install-button");
updateButton = rootVisualElement.Q<Button>("update-button");
reinstallButton = rootVisualElement.Q<Button>("reinstall-button");
clearLockButton = rootVisualElement.Q<Button>("clear-lock-button");
// Installation
installingMessage = rootVisualElement.Q<HelpBox>("installing-message");
installLogContainer = rootVisualElement.Q<VisualElement>("install-log-container");
installLogField = rootVisualElement.Q<TextField>("install-log");
pluginPathField = rootVisualElement.Q<TextField>("plugin-path");
// Database section
QueryDatabaseUIElements();
}
private void BindEvents()
{
// Server controls
autostartToggle?.RegisterValueChangedCallback(evt => {
server.AutoStart = evt.newValue;
});
// Initialize and bind log level dropdown
if (logLevelDropdown != null)
{
logLevelDropdown.Init(server.CurrentLogLevel);
logLevelDropdown.RegisterValueChangedCallback(evt => {
server.CurrentLogLevel = (ToolkitLogger.LogLevel)evt.newValue;
});
}
startButton?.RegisterCallback<ClickEvent>(evt => server.StartServer());
stopButton?.RegisterCallback<ClickEvent>(evt => server.StopServer());
// Server state change events
server.OnServerStarted += OnServerStartedHandler;
server.OnServerStopped += OnServerStoppedHandler;
// Node.js section
var nodejsDownloadBtn = rootVisualElement.Q<Button>("nodejs-download-button");
nodejsDownloadBtn?.RegisterCallback<ClickEvent>(evt =>
Application.OpenURL("https://nodejs.org/"));
var recheckNodejsBtn = rootVisualElement.Q<Button>("recheck-nodejs-button");
recheckNodejsBtn?.RegisterCallback<ClickEvent>(evt => {
hasNodeJS = EditorServerCommandRunner.CheckNodeInstallation();
if (hasNodeJS) cliInstaller.CheckVersion();
UpdateUI();
});
// CLI buttons
installButton?.RegisterCallback<ClickEvent>(evt => InstallCLI());
updateButton?.RegisterCallback<ClickEvent>(evt => InstallCLI());
reinstallButton?.RegisterCallback<ClickEvent>(evt => InstallCLI());
clearLockButton?.RegisterCallback<ClickEvent>(evt => {
cliInstaller.ClearInstallationLock();
UpdateUI();
});
// Path management
var openPathBtn = rootVisualElement.Q<Button>("open-path-button");
openPathBtn?.RegisterCallback<ClickEvent>(evt => {
string path = EditorServerPathManager.FindPluginScriptsPath(
EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null));
if (!string.IsNullOrEmpty(path) && System.IO.Directory.Exists(path))
{
EditorUtility.RevealInFinder(path);
}
else
{
EditorUtility.DisplayDialog("Error", "Plugin scripts path not found.", "OK");
}
});
var browsePathBtn = rootVisualElement.Q<Button>("browse-path-button");
browsePathBtn?.RegisterCallback<ClickEvent>(evt => {
string currentPath = EditorServerPathManager.FindPluginScriptsPath(
EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null))
?? EditorServerPathManager.GetDefaultPluginScriptsPath();
string selectedPath = EditorUtility.OpenFolderPanel(
"Select Plugin Scripts Path",
System.IO.Path.GetDirectoryName(currentPath),
"");
if (!string.IsNullOrEmpty(selectedPath))
{
// Validate the path has package.json
if (System.IO.File.Exists(System.IO.Path.Combine(selectedPath, "package.json")))
{
EditorPrefs.SetString(PREF_KEY_PLUGIN_PATH, selectedPath);
cliInstaller = new EditorServerCLIInstaller(selectedPath);
cliInstaller.CheckVersion();
UpdateUI();
}
else
{
EditorUtility.DisplayDialog("Invalid Path",
"Selected folder does not contain package.json.\nPlease select the 'scripts' folder containing CLI scripts.",
"OK");
}
}
});
var resetPathBtn = rootVisualElement.Q<Button>("reset-path-button");
resetPathBtn?.RegisterCallback<ClickEvent>(evt => {
EditorPrefs.DeleteKey(PREF_KEY_PLUGIN_PATH);
cliInstaller = new EditorServerCLIInstaller(null);
cliInstaller.CheckVersion();
UpdateUI();
});
// Documentation
var openDocsBtn = rootVisualElement.Q<Button>("open-docs-button");
openDocsBtn?.RegisterCallback<ClickEvent>(evt => {
Application.OpenURL("https://github.com/Dev-GOM/claude-code-marketplace/tree/main/plugins/unity-editor-toolkit");
});
// Database section
BindDatabaseEvents();
}
private void InstallCLI()
{
cliInstaller.InstallOrUpdate();
UpdateUI();
}
private void UpdateUI()
{
if (rootVisualElement == null || serverStatusLabel == null) return;
// Update server status
UpdateServerStatus();
// Update CLI status
UpdateCLIStatus();
// Update database status
UpdateDatabaseUI();
}
private void UpdateServerStatus()
{
// Update data properties (UI auto-updates via data binding)
windowData.ServerIsRunning = server.IsRunning;
windowData.ServerPort = server.Port;
windowData.ConnectedClients = server.ConnectedClients;
windowData.AutoStart = server.AutoStart;
// Update status indicator classes (CSS classes cannot be bound)
if (statusIndicator != null)
{
statusIndicator.RemoveFromClassList("status-stopped");
statusIndicator.RemoveFromClassList("status-running");
statusIndicator.AddToClassList(server.IsRunning ? "status-running" : "status-stopped");
}
// Update button states (not bound to data)
autostartToggle.SetEnabled(!server.IsRunning);
startButton.SetEnabled(!server.IsRunning);
stopButton.SetEnabled(server.IsRunning);
}
private void UpdateCLIStatus()
{
bool installInProgress = cliInstaller.IsInstallationInProgress();
// Show/hide Node.js sections
if (!hasNodeJS)
{
nodejsMissingSection?.RemoveFromClassList("hidden");
cliStatusSection?.AddToClassList("hidden");
return;
}
nodejsMissingSection?.AddToClassList("hidden");
cliStatusSection?.RemoveFromClassList("hidden");
// Update version data (UI auto-updates via data binding)
windowData.PackageVersion = cliInstaller.PluginVersion ?? "Unknown";
windowData.CLIVersion = cliInstaller.LocalCLIVersion != null
? $"✅ {cliInstaller.LocalCLIVersion}"
: "❌ Not Installed";
// Hide all status messages first
installProgressHelp?.AddToClassList("hidden");
notInstalledHelp?.AddToClassList("hidden");
updateMinorHelp?.AddToClassList("hidden");
updateMajorHelp?.AddToClassList("hidden");
upToDateHelp?.AddToClassList("hidden");
// Show appropriate status message
if (installInProgress)
{
installProgressHelp?.RemoveFromClassList("hidden");
}
else if (cliInstaller.LocalCLIVersion == null)
{
notInstalledHelp?.RemoveFromClassList("hidden");
}
else if (cliInstaller.UpdateAvailable)
{
bool isMinorUpdate = EditorServerCLIInstaller.IsMinorVersionDifference(
cliInstaller.LocalCLIVersion, cliInstaller.HomeCLIVersion);
if (isMinorUpdate)
{
if (updateMinorHelp != null)
{
updateMinorHelp.text = $"CLI update available: {cliInstaller.LocalCLIVersion} → {cliInstaller.HomeCLIVersion}\n(Minor update, current version still works)";
updateMinorHelp.RemoveFromClassList("hidden");
}
}
else
{
if (updateMajorHelp != null)
{
updateMajorHelp.text = $"CLI update available: {cliInstaller.LocalCLIVersion} → {cliInstaller.HomeCLIVersion}\n(Recommended to update)";
updateMajorHelp.RemoveFromClassList("hidden");
}
}
}
else
{
upToDateHelp?.RemoveFromClassList("hidden");
}
// Button visibility
UpdateButtonVisibility(installInProgress);
// Installation progress
if (cliInstaller.IsInstalling)
{
installingMessage?.RemoveFromClassList("hidden");
}
else
{
installingMessage?.AddToClassList("hidden");
}
// Installation log (only on error)
UpdateInstallLog();
// Plugin path
UpdatePluginPath();
}
private void UpdateButtonVisibility(bool installInProgress)
{
// Hide all buttons first
installButton?.AddToClassList("hidden");
updateButton?.AddToClassList("hidden");
reinstallButton?.AddToClassList("hidden");
clearLockButton?.AddToClassList("hidden");
bool canInstall = !cliInstaller.IsInstalling && !installInProgress;
// Show appropriate button
if (cliInstaller.LocalCLIVersion == null)
{
installButton?.RemoveFromClassList("hidden");
installButton?.SetEnabled(canInstall);
}
else if (cliInstaller.UpdateAvailable)
{
updateButton?.RemoveFromClassList("hidden");
updateButton?.SetEnabled(canInstall);
}
else
{
reinstallButton?.RemoveFromClassList("hidden");
reinstallButton?.SetEnabled(canInstall);
}
if (installInProgress)
{
clearLockButton?.RemoveFromClassList("hidden");
}
}
private void UpdateInstallLog()
{
if (string.IsNullOrEmpty(cliInstaller.InstallLog))
{
installLogContainer?.AddToClassList("hidden");
return;
}
// Check if log contains error
string logLower = cliInstaller.InstallLog.ToLower();
bool hasError = logLower.Contains("error") ||
logLower.Contains("failed") ||
logLower.Contains("exception") ||
logLower.Contains("cannot find") ||
logLower.Contains("command not found");
if (hasError)
{
installLogContainer?.RemoveFromClassList("hidden");
if (installLogField != null)
installLogField.value = cliInstaller.InstallLog;
}
else
{
installLogContainer?.AddToClassList("hidden");
}
}
private void UpdatePluginPath()
{
if (pluginPathField == null) return;
string pathOverride = EditorPrefs.GetString(PREF_KEY_PLUGIN_PATH, null);
string displayPath = EditorServerPathManager.FindPluginScriptsPath(pathOverride)
?? EditorServerPathManager.GetDefaultPluginScriptsPath();
pluginPathField.value = displayPath;
}
private void Update()
{
bool needsUpdate = false;
// Play Mode state change detection
if (Application.isPlaying != wasPlaying)
{
wasPlaying = Application.isPlaying;
needsUpdate = true;
}
// Periodic update
float currentTime = (float)EditorApplication.timeSinceStartup;
if (currentTime - lastUpdateTime > UI_UPDATE_INTERVAL_SECONDS)
{
lastUpdateTime = currentTime;
needsUpdate = true;
}
// Update UI when needed
if (needsUpdate)
{
UpdateUI();
}
}
private void LogInitializationStatus()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
sb.AppendLine("✓ Unity Editor Toolkit - Initialized");
sb.AppendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
// Node.js 상태
if (hasNodeJS)
{
string nodeVersion = EditorServerCommandRunner.GetNodeVersion();
sb.AppendLine($" Node.js: {nodeVersion}");
}
else
{
sb.AppendLine(" Node.js: Not installed");
}
// CLI 상태
if (cliInstaller != null)
{
string cliVersion = cliInstaller.LocalCLIVersion;
sb.AppendLine($" CLI Version: {(string.IsNullOrEmpty(cliVersion) ? "Not installed" : cliVersion)}");
}
// Database 상태
if (currentDbConfig != null)
{
sb.AppendLine($" Database: {(currentDbConfig.EnableDatabase ? "Enabled" : "Disabled")}");
if (currentDbConfig.EnableDatabase)
{
sb.AppendLine($" - WAL Mode: {(currentDbConfig.EnableWAL ? "Enabled" : "Disabled")}");
sb.AppendLine($" - File Path: {currentDbConfig.DatabaseFilePath}");
}
}
// Server 상태
sb.AppendLine($" WebSocket Server: {(server.IsRunning ? $"Running (Port {server.Port})" : "Stopped")}");
sb.AppendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
ToolkitLogger.Log("EditorServerWindow", sb.ToString());
}
#region Executable Methods (CLI)
/// <summary>
/// Reinstall Unity Editor Toolkit CLI (executable via CLI)
/// </summary>
[ExecutableMethod("reinstall-cli", "Reinstall Unity Editor Toolkit CLI")]
public static void ReinstallCLI()
{
var window = GetWindow<EditorServerWindow>("Unity Editor Toolkit");
if (window == null)
{
ToolkitLogger.LogError("EditorServerWindow", "Failed to get window instance for CLI reinstall");
throw new System.Exception("Failed to get Unity Editor Toolkit window instance");
}
if (window.cliInstaller == null)
{
ToolkitLogger.LogError("EditorServerWindow", "CLI installer is not initialized");
throw new System.Exception("CLI installer is not initialized");
}
ToolkitLogger.Log("EditorServerWindow", "Reinstalling CLI via execute command...");
window.cliInstaller.InstallOrUpdate();
}
#endregion
private void OnDisable()
{
// Unsubscribe from server events
if (server != null)
{
server.OnServerStarted -= OnServerStartedHandler;
server.OnServerStopped -= OnServerStoppedHandler;
}
// UI Toolkit automatically cleans up event handlers when the window closes
// Clear any references to prevent potential memory leaks
statusIndicator = null;
serverStatusLabel = null;
serverPortLabel = null;
connectedClientsLabel = null;
autostartToggle = null;
startButton = null;
stopButton = null;
nodejsMissingSection = null;
cliStatusSection = null;
packageVersionLabel = null;
cliVersionLabel = null;
installProgressHelp = null;
notInstalledHelp = null;
updateMinorHelp = null;
updateMajorHelp = null;
upToDateHelp = null;
installButton = null;
updateButton = null;
reinstallButton = null;
clearLockButton = null;
installingMessage = null;
installLogContainer = null;
installLogField = null;
pluginPathField = null;
// Database section cleanup
CleanupDatabaseUI();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 570a7561ca0226e44a98c146ae5c1b02

View File

@@ -0,0 +1,162 @@
/* Root Container */
.root-container {
padding: 10px;
flex-grow: 1;
}
/* Section Styling */
.section {
background-color: rgba(0, 0, 0, 0.1);
border-color: rgba(0, 0, 0, 0.3);
border-width: 1px;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
}
.section-title {
font-size: 14px;
-unity-font-style: bold;
margin-bottom: 5px;
}
.section-subtitle {
font-size: 12px;
-unity-font-style: bold;
margin-top: 10px;
margin-bottom: 5px;
}
/* Status Row */
.status-row {
flex-direction: row;
margin-bottom: 3px;
align-items: center;
}
.label-key {
min-width: 150px;
margin-right: 10px;
}
.label-value {
flex-grow: 1;
-unity-font-style: bold;
}
/* Status Indicator */
.status-indicator-container {
flex-direction: row;
align-items: center;
flex-grow: 1;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 6px;
margin-right: 8px;
}
.status-stopped {
background-color: rgb(150, 150, 150);
}
.status-running {
background-color: rgb(80, 200, 80);
}
.status-error {
background-color: rgb(220, 80, 80);
}
/* Toggle Field */
.toggle-field {
margin-top: 5px;
margin-bottom: 5px;
}
/* Button Row */
.button-row {
flex-direction: row;
margin-top: 5px;
margin-bottom: 5px;
}
.button-row > Button {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
margin-left: 2px;
margin-right: 2px;
}
/* Buttons */
.action-button {
height: 30px;
}
.primary-button {
height: 30px;
}
.secondary-button {
height: 28px;
}
/* Path Field */
.path-field {
margin-top: 5px;
margin-bottom: 5px;
}
/* Log Field */
.log-field {
height: 100px;
margin-top: 5px;
margin-bottom: 5px;
}
/* Info Scroll */
.info-scroll {
max-height: 300px;
min-height: 150px;
}
/* Hidden Class */
.hidden {
display: none;
}
/* Help Box Spacing */
.unity-help-box {
margin-top: 5px;
margin-bottom: 5px;
}
/* Toolbar Extension Styles */
.unity-toolbar-element {
flex-direction: row;
align-items: center;
padding-left: 8px;
padding-right: 8px;
}
.unity-toolbar-label {
font-size: 11px;
-unity-font-style: bold;
margin-right: 5px;
}
.unity-toolbar-button {
font-size: 11px;
padding: 2px 5px;
}
.server-running {
color: rgb(76, 255, 76);
}
.server-stopped {
color: rgb(255, 76, 76);
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: be9c07b31346a74489940e8850d3ac83
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
disableValidation: 0

View File

@@ -0,0 +1,274 @@
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="True">
<ui:VisualElement name="root" class="root-container">
<ui:TabView>
<!-- Tab 1: Server & CLI -->
<ui:Tab label="🖥️ Server &amp; CLI" name="server-cli-tab">
<ui:ScrollView mode="Vertical" class="scroll-container">
<ui:VisualElement style="padding: 10px;">
<!-- Server Status Section -->
<ui:VisualElement name="server-section" class="section">
<ui:Label text="🖥️ Server Status" class="section-title" />
<ui:VisualElement class="status-row">
<ui:Label text="📡 Status:" class="label-key" />
<ui:VisualElement class="status-indicator-container">
<ui:VisualElement name="status-indicator" class="status-indicator status-stopped" />
<ui:Label name="server-status" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="ServerStatusText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="🔌 Port:" class="label-key" />
<ui:Label name="server-port" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="ServerPortText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="👥 Connected Clients:" class="label-key" />
<ui:Label name="connected-clients" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="ConnectedClientsText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:Toggle name="autostart-toggle" label="🚀 Auto-start server on Unity launch" class="toggle-field">
<Bindings>
<ui:DataBinding property="value" data-source-path="AutoStart" binding-mode="TwoWay" />
</Bindings>
</ui:Toggle>
<ui:VisualElement class="status-row">
<ui:Label text="📝 Log Level:" class="label-key" />
<uie:EnumField name="log-level-dropdown" class="enum-field" />
</ui:VisualElement>
<ui:VisualElement class="button-row">
<ui:Button name="start-button" text="▶️ Start Server" class="action-button" />
<ui:Button name="stop-button" text="⏹️ Stop Server" class="action-button" />
</ui:VisualElement>
<ui:HelpBox name="server-info" text="Server runs automatically in the background.&#10;Works in both Edit Mode and Play Mode." message-type="Info" />
</ui:VisualElement>
<!-- CLI Status Section -->
<ui:VisualElement name="cli-section" class="section">
<ui:Label text="💻 CLI Scripts" class="section-title" />
<!-- Node.js not installed view -->
<ui:VisualElement name="nodejs-missing" class="hidden">
<ui:HelpBox text="Node.js is not installed or not in PATH.&#10;&#10;Please install Node.js from https://nodejs.org/&#10;Recommended version: 18.x or higher&#10;&#10;After installation, restart Unity Editor." message-type="Error" />
<ui:Button name="nodejs-download-button" text="🌐 Open Node.js Download Page" class="primary-button" />
<ui:Button name="recheck-nodejs-button" text="🔄 Recheck Node.js Installation" class="secondary-button" />
</ui:VisualElement>
<!-- Node.js installed view -->
<ui:VisualElement name="cli-status" class="hidden">
<ui:VisualElement class="status-row">
<ui:Label text="📦 Package Version:" class="label-key" />
<ui:Label name="package-version" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="PackageVersion" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="💿 Local CLI Version:" class="label-key" />
<ui:Label name="cli-version" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="CLIVersion" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<!-- Status messages -->
<ui:HelpBox name="install-progress" text="CLI installation is in progress...&#10;If this persists for more than 10 minutes, click 'Clear Lock' below." message-type="Info" class="hidden" />
<ui:HelpBox name="not-installed" text="CLI scripts not installed. Click 'Install CLI Scripts' to set up." message-type="Warning" class="hidden" />
<ui:HelpBox name="update-minor" text="CLI update available" message-type="Info" class="hidden" />
<ui:HelpBox name="update-major" text="CLI update available (Recommended to update)" message-type="Warning" class="hidden" />
<ui:HelpBox name="up-to-date" text="CLI scripts up-to-date ✓" message-type="Info" class="hidden" />
<!-- Action buttons -->
<ui:Button name="install-button" text="📥 Install CLI Scripts" class="primary-button hidden" />
<ui:Button name="update-button" text="⚠️ Update CLI Scripts" class="primary-button hidden" />
<ui:Button name="reinstall-button" text="🔄 Reinstall CLI Scripts" class="secondary-button hidden" />
<ui:Button name="clear-lock-button" text="🔓 Clear Lock (if installation stuck)" class="secondary-button hidden" />
<!-- Installation progress -->
<ui:HelpBox name="installing-message" text="Installing CLI scripts...&#10;Please wait, this may take a few minutes." message-type="Info" class="hidden" />
<!-- Installation error log -->
<ui:VisualElement name="install-log-container" class="hidden">
<ui:Label text="📋 Last Installation Log (Error):" class="section-subtitle" />
<ui:TextField name="install-log" multiline="true" readonly="true" class="log-field" />
</ui:VisualElement>
<!-- Plugin Scripts Path Configuration -->
<ui:Label text="📁 Plugin Scripts Path" class="section-subtitle" />
<ui:TextField name="plugin-path" readonly="true" class="path-field" />
<ui:VisualElement class="button-row">
<ui:Button name="open-path-button" text="📂 Open Folder" class="secondary-button" />
<ui:Button name="browse-path-button" text="🔍 Browse..." class="secondary-button" />
<ui:Button name="reset-path-button" text="↩️ Reset to Default" class="secondary-button" />
</ui:VisualElement>
<ui:HelpBox text="This is the source path where CLI scripts are loaded from.&#10;Default: Claude plugin folder (~/.claude/plugins/.../unity-editor-toolkit)" message-type="None" />
</ui:VisualElement>
</ui:VisualElement>
</ui:VisualElement>
</ui:ScrollView>
</ui:Tab>
<!-- Tab 2: Database -->
<ui:Tab label="💾 Database" name="database-tab">
<ui:ScrollView mode="Vertical" class="scroll-container">
<ui:VisualElement style="padding: 10px;">
<!-- Database Section -->
<ui:VisualElement name="database-section" class="section">
<ui:Label text="💾 Database (SQLite)" class="section-title" />
<!-- Setup Wizard Section -->
<ui:VisualElement name="db-setup-section">
<ui:Label text="⚙️ Setup Status" class="section-subtitle" />
<!-- Setup Status Checks -->
<ui:VisualElement class="status-row">
<ui:Label text="📚 SQLite:" class="label-key" />
<ui:Label name="db-setup-sqlite-status" text="✅ Included (unity-sqlite-net)" class="label-value" />
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="⚡ UniTask:" class="label-key" />
<ui:Label name="db-setup-unitask-status" text="✅ Auto-installed (package.json)" class="label-value" />
</ui:VisualElement>
<!-- Setup/Reinstall Button -->
<ui:Button name="db-setup-button" text="🔄 Reinstall Database" class="primary-button" />
<!-- Setup Progress -->
<ui:VisualElement name="db-setup-progress-container" class="hidden">
<ui:Label name="db-setup-progress-label" text="재설치 진행 중..." class="section-subtitle" />
<ui:ProgressBar name="db-setup-progress-bar" value="0" />
<ui:Label name="db-setup-step-label" text="" class="label-value" />
</ui:VisualElement>
<!-- Setup Messages -->
<ui:HelpBox name="db-setup-info-help" text="Reinstall Database 버튼을 클릭하면:&#10;1. 데이터베이스 파일 재생성 (Application.persistentDataPath)&#10;2. 스키마 마이그레이션 재실행&#10;&#10;⚠️ 기존 데이터가 삭제됩니다. 백업을 권장합니다." message-type="Warning" />
<ui:HelpBox name="db-setup-success-help" text="" message-type="Info" class="hidden" />
<ui:HelpBox name="db-setup-error-help" text="" message-type="Error" class="hidden" />
</ui:VisualElement>
<!-- Enable Database Toggle -->
<ui:Toggle name="db-enable-toggle" label="✅ Enable Database Features" class="toggle-field" />
<!-- Database Configuration -->
<ui:VisualElement name="db-config-section" class="hidden">
<ui:Label text="📄 Database File Settings" class="section-subtitle" />
<ui:TextField name="db-filepath-field" readonly="true" class="path-field" tooltip="SQLite 데이터베이스 파일 경로&#10;기본 위치: Application.persistentDataPath&#10;단일 파일로 모든 데이터 저장" />
<ui:VisualElement class="button-row">
<ui:Button name="db-browse-button" text="🔍 Browse..." class="secondary-button" tooltip="다른 위치에 DB 파일 저장" />
<ui:Button name="db-open-folder-button" text="📂 Open Folder" class="secondary-button" tooltip="DB 파일이 있는 폴더 열기" />
<ui:Button name="db-reset-path-button" text="↩️ Reset to Default" class="secondary-button" tooltip="기본 경로로 재설정" />
</ui:VisualElement>
<ui:Toggle name="db-wal-toggle" label="⚡ Enable WAL Mode" class="toggle-field" tooltip="Write-Ahead Logging 모드&#10;• 성능 향상 (최대 10배)&#10;• 동시 읽기 가능&#10;• 쓰기 중에도 읽기 가능&#10;권장: 활성화 (기본값)" />
<!-- Status and Controls Section -->
<ui:VisualElement name="db-status-controls-section">
<!-- Connection Status -->
<ui:Label text="📊 Status" class="section-subtitle" />
<ui:VisualElement class="status-row">
<ui:Label text="🔌 Connection:" class="label-key" tooltip="SQLite 데이터베이스 연결 상태&#10;Connect 버튼으로 연결" />
<ui:VisualElement class="status-indicator-container">
<ui:VisualElement name="db-status-indicator" class="status-indicator status-stopped" />
<ui:Label name="db-status-label" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbStatusText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="💾 Database File:" class="label-key" tooltip="데이터베이스 파일 생성 여부&#10;Reinstall Database 버튼으로 생성" />
<ui:Label name="db-file-exists-label" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbFileExistsText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="🔄 Sync Status:" class="label-key" tooltip="실시간 GameObject/Component 동기화 상태&#10;(Phase 2에서 구현 예정)" />
<ui:Label name="db-sync-status-label" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbSyncStatusText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<!-- Connection Controls -->
<ui:Label text="🎮 Connection Controls" class="section-subtitle" style="margin-top: 15px;" />
<ui:VisualElement class="button-row">
<ui:Button name="db-test-button" text="🔍 Test Connection" class="action-button" tooltip="데이터베이스 연결 테스트&#10;SQLite 버전 정보 확인" />
<ui:Button name="db-connect-button" text="🔌 Connect" class="action-button" tooltip="데이터베이스에 연결&#10;Command History 활성화" />
<ui:Button name="db-disconnect-button" text="⏸️ Disconnect" class="action-button hidden" tooltip="데이터베이스 연결 해제" />
</ui:VisualElement>
<ui:VisualElement class="button-row">
<ui:Button name="db-migrate-button" text="⚙️ Run Migrations" class="primary-button" tooltip="스키마 마이그레이션 실행&#10;테이블/인덱스/트리거 생성" />
<ui:Button name="db-sync-toggle-button" text="🔄 Start Sync" class="secondary-button" tooltip="GameObject/Component 실시간 동기화&#10;(Phase 2에서 구현 예정)" />
</ui:VisualElement>
<!-- Command History (Undo/Redo) -->
<ui:Label text="📜 Command History" class="section-subtitle" style="margin-top: 15px;" />
<ui:VisualElement class="status-row">
<ui:Label text="⟲ Undo Stack:" class="label-key" tooltip="실행 취소 가능한 명령 수&#10;데이터베이스에 영구 저장" />
<ui:Label name="db-undo-count" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbUndoCountText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="status-row">
<ui:Label text="⟳ Redo Stack:" class="label-key" tooltip="다시 실행 가능한 명령 수&#10;Undo 후 사용 가능" />
<ui:Label name="db-redo-count" class="label-value">
<Bindings>
<ui:DataBinding property="text" data-source-path="DbRedoCountText" binding-mode="ToTarget" />
</Bindings>
</ui:Label>
</ui:VisualElement>
<ui:VisualElement class="button-row">
<ui:Button name="db-undo-button" text="⟲ Undo" class="action-button" tooltip="마지막 명령 실행 취소&#10;GameObject/Component 변경 복원" />
<ui:Button name="db-redo-button" text="⟳ Redo" class="action-button" tooltip="취소한 명령 다시 실행" />
<ui:Button name="db-clear-history-button" text="🗑️ Clear History" class="action-button" tooltip="모든 명령 히스토리 삭제&#10;Undo/Redo 불가능" />
</ui:VisualElement>
</ui:VisualElement>
<!-- Status Messages -->
<ui:HelpBox name="db-error-help" text="" message-type="Error" class="hidden" />
<ui:HelpBox name="db-success-help" text="" message-type="Info" class="hidden" />
</ui:VisualElement>
<!-- Database Disabled Message -->
<ui:VisualElement name="db-disabled-section" class="hidden">
<ui:HelpBox text="Database features are disabled.&#10;&#10;Enable to use:&#10;• GameObject/Component persistence&#10;• Command history (Undo/Redo across sessions)&#10;• Scene snapshots&#10;• Complex SQL queries&#10;• Project analytics&#10;&#10;SQLite - 설치 불필요!&#10;• unity-sqlite-net (included in package)&#10;• UniTask (auto-installed)&#10;• Unity 6.0+ 권장&#10;&#10;원클릭 Setup으로 바로 사용 가능합니다!" message-type="None" />
</ui:VisualElement>
</ui:VisualElement>
</ui:VisualElement>
</ui:ScrollView>
</ui:Tab>
</ui:TabView>
</ui:VisualElement>
</ui:UXML>

View File

@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 03acb6ac62524c941b8618ae7a99076c
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}

View File

@@ -0,0 +1,220 @@
using System;
using System.Runtime.CompilerServices;
using Unity.Properties;
using UnityEngine.UIElements;
namespace UnityEditorToolkit.Editor
{
/// <summary>
/// EditorServerWindow의 데이터 바인딩을 위한 데이터 소스 클래스
/// INotifyBindablePropertyChanged를 구현하여 UI 자동 업데이트
/// </summary>
public class EditorServerWindowData : INotifyBindablePropertyChanged
{
#region INotifyBindablePropertyChanged
public event EventHandler<BindablePropertyChangedEventArgs> propertyChanged;
private void Notify([CallerMemberName] string propertyName = "")
{
propertyChanged?.Invoke(this, new BindablePropertyChangedEventArgs(propertyName));
}
#endregion
#region Server Status Data
private bool serverIsRunning = false;
private int serverPort = 9500;
private int connectedClients = 0;
private bool autoStart = false;
[CreateProperty]
public bool ServerIsRunning
{
get => serverIsRunning;
set
{
if (serverIsRunning == value) return;
serverIsRunning = value;
Notify();
Notify(nameof(ServerStatusText));
}
}
[CreateProperty]
public string ServerStatusText => ServerIsRunning ? "▶️ Running ✓" : "⏹️ Stopped";
[CreateProperty]
public int ServerPort
{
get => serverPort;
set
{
if (serverPort == value) return;
serverPort = value;
Notify();
Notify(nameof(ServerPortText));
}
}
[CreateProperty]
public string ServerPortText => ServerPort.ToString();
[CreateProperty]
public int ConnectedClients
{
get => connectedClients;
set
{
if (connectedClients == value) return;
connectedClients = value;
Notify();
Notify(nameof(ConnectedClientsText));
}
}
[CreateProperty]
public string ConnectedClientsText => ConnectedClients.ToString();
[CreateProperty]
public bool AutoStart
{
get => autoStart;
set
{
if (autoStart == value) return;
autoStart = value;
Notify();
}
}
#endregion
#region CLI Status Data
private bool hasNodeJS = false;
private string packageVersion = "Unknown";
private string cliVersion = "❌ Not Installed";
[CreateProperty]
public bool HasNodeJS
{
get => hasNodeJS;
set
{
if (hasNodeJS == value) return;
hasNodeJS = value;
Notify();
}
}
[CreateProperty]
public string PackageVersion
{
get => packageVersion;
set
{
if (packageVersion == value) return;
packageVersion = value;
Notify();
}
}
[CreateProperty]
public string CLIVersion
{
get => cliVersion;
set
{
if (cliVersion == value) return;
cliVersion = value;
Notify();
}
}
#endregion
#region Database Status Data
private bool dbIsConnected = false;
private bool dbFileExists = false;
private bool dbIsSyncing = false;
private int dbUndoCount = 0;
private int dbRedoCount = 0;
[CreateProperty]
public bool DbIsConnected
{
get => dbIsConnected;
set
{
if (dbIsConnected == value) return;
dbIsConnected = value;
Notify();
Notify(nameof(DbStatusText));
}
}
[CreateProperty]
public string DbStatusText => DbIsConnected ? "✅ Connected" : "❌ Not Connected";
[CreateProperty]
public bool DbFileExists
{
get => dbFileExists;
set
{
if (dbFileExists == value) return;
dbFileExists = value;
Notify();
Notify(nameof(DbFileExistsText));
}
}
[CreateProperty]
public string DbFileExistsText => DbFileExists ? "✅ Created" : "❌ Not Created";
[CreateProperty]
public bool DbIsSyncing
{
get => dbIsSyncing;
set
{
if (dbIsSyncing == value) return;
dbIsSyncing = value;
Notify();
Notify(nameof(DbSyncStatusText));
}
}
[CreateProperty]
public string DbSyncStatusText => DbIsSyncing ? "✅ Running" : "🚧 (구현예정)";
[CreateProperty]
public int DbUndoCount
{
get => dbUndoCount;
set
{
if (dbUndoCount == value) return;
dbUndoCount = value;
Notify();
Notify(nameof(DbUndoCountText));
}
}
[CreateProperty]
public string DbUndoCountText => $"🔢 {DbUndoCount}";
[CreateProperty]
public int DbRedoCount
{
get => dbRedoCount;
set
{
if (dbRedoCount == value) return;
dbRedoCount = value;
Notify();
Notify(nameof(DbRedoCountText));
}
}
[CreateProperty]
public string DbRedoCountText => $"🔢 {DbRedoCount}";
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b7289343a5ced3c4a994e9dc51df2763

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 61c265daf09fed041bd8e2817b7aca18

View File

@@ -0,0 +1,232 @@
using System.Reflection;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditorToolkit.Editor.Server;
using UnityEditorToolkit.Editor.Database;
namespace UnityEditorToolkit.Editor
{
/// <summary>
/// Unity Editor Toolbar에 서버 및 DB 연결 상태를 표시 (Reflection 기반)
/// </summary>
[InitializeOnLoad]
public static class EditorToolbarExtension
{
private static VisualElement toolbarRoot;
private static VisualElement customToolbarLeft;
private static Label serverStatusLabel;
private static Label dbStatusLabel;
private static VisualElement statusContainer;
static EditorToolbarExtension()
{
EditorApplication.update -= OnUpdate;
EditorApplication.update += OnUpdate;
}
private static void TryInitialize()
{
if (toolbarRoot != null)
{
return;
}
var toolbarType = typeof(UnityEditor.Editor).Assembly.GetType("UnityEditor.Toolbar");
if (toolbarType == null)
{
return;
}
var toolbarObj = toolbarType.GetField("get").GetValue(null);
if (toolbarObj == null)
{
return;
}
toolbarRoot = (VisualElement)toolbarType.GetField("m_Root",
BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(toolbarObj);
if (toolbarRoot == null)
{
return;
}
// PlayModeButtons 바로 앞에 삽입하기 위해 ToolbarZonePlayMode 찾기
var playModeZone = toolbarRoot.Q("ToolbarZonePlayMode");
customToolbarLeft = new VisualElement
{
name = "unity-editor-toolkit-toolbar",
style =
{
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
marginRight = 8,
},
};
if (playModeZone != null)
{
// ToolbarZonePlayMode의 맨 앞에 삽입
playModeZone.Insert(0, customToolbarLeft);
}
else
{
// PlayModeZone을 못 찾으면 ToolbarZoneLeftAlign에 추가
var toolbarLeft = toolbarRoot.Q("ToolbarZoneLeftAlign");
if (toolbarLeft != null)
{
toolbarLeft.Add(customToolbarLeft);
}
else
{
return;
}
}
InitializeServerStatus();
}
private static void InitializeServerStatus()
{
// 클릭 가능한 컨테이너 (전체가 버튼처럼 동작)
statusContainer = new VisualElement
{
name = "unity-editor-toolkit-status",
style =
{
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
paddingLeft = 6,
paddingRight = 6,
paddingTop = 2,
paddingBottom = 2,
backgroundColor = new Color(0.2f, 0.2f, 0.2f, 0.3f),
borderTopLeftRadius = 3,
borderTopRightRadius = 3,
borderBottomLeftRadius = 3,
borderBottomRightRadius = 3,
},
};
// 마우스 이벤트 추가
statusContainer.RegisterCallback<MouseDownEvent>(evt =>
{
if (evt.button == 0) // 왼쪽 클릭
{
ShowWindowMenu();
}
});
// 마우스 오버 효과
statusContainer.RegisterCallback<MouseEnterEvent>(evt =>
{
statusContainer.style.backgroundColor = new Color(0.3f, 0.3f, 0.3f, 0.5f);
});
statusContainer.RegisterCallback<MouseLeaveEvent>(evt =>
{
statusContainer.style.backgroundColor = new Color(0.2f, 0.2f, 0.2f, 0.3f);
});
// 서버 상태 라벨
serverStatusLabel = new Label("●")
{
name = "server-status-label",
style =
{
fontSize = 11,
unityFontStyleAndWeight = FontStyle.Bold,
marginRight = 5,
},
};
// DB 상태 라벨
dbStatusLabel = new Label("●")
{
name = "db-status-label",
style =
{
fontSize = 11,
unityFontStyleAndWeight = FontStyle.Bold,
marginRight = 3,
},
};
// 드롭다운 화살표
var dropdownArrow = new Label("▼")
{
style =
{
fontSize = 8,
color = new Color(0.8f, 0.8f, 0.8f, 1f),
},
};
statusContainer.Add(serverStatusLabel);
statusContainer.Add(dbStatusLabel);
statusContainer.Add(dropdownArrow);
customToolbarLeft.Add(statusContainer);
}
private static void OnUpdate()
{
TryInitialize();
UpdateServerStatus();
}
private static void UpdateServerStatus()
{
if (serverStatusLabel == null || dbStatusLabel == null || statusContainer == null)
{
return;
}
// 서버 상태 업데이트
var server = EditorWebSocketServer.Instance;
bool serverIsRunning = server != null && server.IsRunning;
if (serverIsRunning)
{
serverStatusLabel.text = $"● {server.Port}";
serverStatusLabel.tooltip = $"WebSocket Server: Running\nPort: {server.Port}\nClients: {server.ConnectedClients}";
serverStatusLabel.style.color = new Color(0.3f, 1f, 0.3f);
}
else
{
serverStatusLabel.text = "●";
serverStatusLabel.tooltip = "WebSocket Server: Stopped";
serverStatusLabel.style.color = new Color(1f, 0.3f, 0.3f);
}
// DB 상태 업데이트
bool dbIsConnected = DatabaseManager.Instance != null && DatabaseManager.Instance.IsConnected;
if (dbIsConnected)
{
dbStatusLabel.text = "● DB";
dbStatusLabel.tooltip = "Database: Connected";
dbStatusLabel.style.color = new Color(0.3f, 1f, 0.3f);
}
else
{
dbStatusLabel.text = "● DB";
dbStatusLabel.tooltip = "Database: Disconnected";
dbStatusLabel.style.color = new Color(1f, 0.3f, 0.3f);
}
// 전체 컨테이너 tooltip (종합 정보)
string serverStatus = serverIsRunning ? $"Running (:{server.Port})" : "Stopped";
string dbStatus = dbIsConnected ? "Connected" : "Disconnected";
statusContainer.tooltip = $"Unity Editor Toolkit\n\nServer: {serverStatus}\nDatabase: {dbStatus}\n\nClick to open menu";
}
private static void ShowWindowMenu()
{
var menu = new GenericMenu();
menu.AddItem(new GUIContent("Open Unity Editor Toolkit"), false, () => EditorServerWindow.ShowWindow());
menu.ShowAsContext();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cbcf4459cf5d8634389a45be6ef10bc8

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5c86997cf856c7043a54da66e5f0919f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,511 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Editor.Database;
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Analytics and caching
/// </summary>
public class AnalyticsHandler : BaseHandler
{
public override string Category => "Analytics";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "GetProjectStats":
return HandleGetProjectStats(request);
case "GetSceneStats":
return HandleGetSceneStats(request);
case "SetCache":
return HandleSetCache(request);
case "GetCache":
return HandleGetCache(request);
case "ClearCache":
return HandleClearCache(request);
case "ListCache":
return HandleListCache(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
/// <summary>
/// Get project-wide statistics
/// </summary>
private object HandleGetProjectStats(JsonRpcRequest request)
{
if (!DatabaseManager.Instance.IsConnected)
{
throw new Exception("Database is not connected");
}
var connection = DatabaseManager.Instance.Connector?.Connection;
if (connection == null)
{
throw new Exception("Failed to get database connection");
}
// Check cache first
var cacheKey = "project_stats";
var cacheResult = GetCacheData(connection, cacheKey);
if (cacheResult != null)
{
return JsonUtility.FromJson<ProjectStatsResult>(cacheResult);
}
// Calculate stats
int totalScenes = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM scenes");
int totalObjects = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM gameobjects WHERE is_deleted = 0");
int totalComponents = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM components");
int totalTransforms = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM transforms");
int totalSnapshots = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM snapshots");
int commandHistoryCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM command_history");
// Get most used components
var componentsSql = @"
SELECT component_type, COUNT(*) as count
FROM components
GROUP BY component_type
ORDER BY count DESC
LIMIT 10
";
var componentStats = connection.Query<ComponentStatRecord>(componentsSql);
var topComponents = componentStats.Select(c => new ComponentStat
{
componentType = c.component_type,
count = c.count
}).ToList();
var result = new ProjectStatsResult
{
success = true,
totalScenes = totalScenes,
totalObjects = totalObjects,
totalComponents = totalComponents,
totalTransforms = totalTransforms,
totalSnapshots = totalSnapshots,
commandHistoryCount = commandHistoryCount,
topComponents = topComponents
};
// Cache result
var jsonData = JsonUtility.ToJson(result);
SetCacheData(connection, cacheKey, jsonData, 3600); // Cache for 1 hour
return result;
}
/// <summary>
/// Get current scene statistics
/// </summary>
private object HandleGetSceneStats(JsonRpcRequest request)
{
if (!DatabaseManager.Instance.IsConnected)
{
throw new Exception("Database is not connected");
}
var connection = DatabaseManager.Instance.Connector?.Connection;
if (connection == null)
{
throw new Exception("Failed to get database connection");
}
var scene = SceneManager.GetActiveScene();
var cacheKey = $"scene_stats_{scene.path}";
// Check cache
var cacheResult = GetCacheData(connection, cacheKey);
if (cacheResult != null)
{
return JsonUtility.FromJson<SceneStatsResult>(cacheResult);
}
// Get scene ID
var sceneIdSql = "SELECT scene_id FROM scenes WHERE scene_path = ?";
var sceneIds = connection.Query<SceneIdRecord>(sceneIdSql, scene.path);
if (sceneIds.Count() == 0)
{
return new SceneStatsResult
{
success = true,
sceneName = scene.name,
scenePath = scene.path,
objectCount = 0,
componentCount = 0,
snapshotCount = 0,
message = "Scene not synced to database"
};
}
int sceneId = sceneIds.First().scene_id;
// Calculate stats
int objectCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM gameobjects WHERE scene_id = ? AND is_deleted = 0", sceneId);
int componentCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM components WHERE object_id IN (SELECT object_id FROM gameobjects WHERE scene_id = ?)", sceneId);
int snapshotCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM snapshots WHERE scene_id = ?", sceneId);
int transformHistoryCount = connection.ExecuteScalar<int>("SELECT COUNT(*) FROM transforms WHERE object_id IN (SELECT object_id FROM gameobjects WHERE scene_id = ?)", sceneId);
var result = new SceneStatsResult
{
success = true,
sceneName = scene.name,
scenePath = scene.path,
sceneId = sceneId,
objectCount = objectCount,
componentCount = componentCount,
snapshotCount = snapshotCount,
transformHistoryCount = transformHistoryCount,
message = "Scene statistics retrieved successfully"
};
// Cache result
var jsonData = JsonUtility.ToJson(result);
SetCacheData(connection, cacheKey, jsonData, 300); // Cache for 5 minutes
return result;
}
/// <summary>
/// Set cache data
/// </summary>
private object HandleSetCache(JsonRpcRequest request)
{
var param = ValidateParam<SetCacheParams>(request, "key");
if (!DatabaseManager.Instance.IsConnected)
{
throw new Exception("Database is not connected");
}
var connection = DatabaseManager.Instance.Connector?.Connection;
if (connection == null)
{
throw new Exception("Failed to get database connection");
}
int ttl = param.ttl > 0 ? param.ttl : 3600; // Default 1 hour
SetCacheData(connection, param.key, param.data, ttl);
return new CacheResult
{
success = true,
key = param.key,
message = $"Cache set successfully (TTL: {ttl}s)"
};
}
/// <summary>
/// Get cache data
/// </summary>
private object HandleGetCache(JsonRpcRequest request)
{
var param = ValidateParam<GetCacheParams>(request, "key");
if (!DatabaseManager.Instance.IsConnected)
{
throw new Exception("Database is not connected");
}
var connection = DatabaseManager.Instance.Connector?.Connection;
if (connection == null)
{
throw new Exception("Failed to get database connection");
}
var data = GetCacheData(connection, param.key);
if (data == null)
{
return new GetCacheResult
{
success = false,
key = param.key,
data = null,
message = "Cache not found or expired"
};
}
return new GetCacheResult
{
success = true,
key = param.key,
data = data,
message = "Cache retrieved successfully"
};
}
/// <summary>
/// Clear cache
/// </summary>
private object HandleClearCache(JsonRpcRequest request)
{
var param = request.GetParams<ClearCacheParams>();
if (!DatabaseManager.Instance.IsConnected)
{
throw new Exception("Database is not connected");
}
var connection = DatabaseManager.Instance.Connector?.Connection;
if (connection == null)
{
throw new Exception("Failed to get database connection");
}
int deletedCount;
if (param != null && !string.IsNullOrEmpty(param.key))
{
// Clear specific key
deletedCount = connection.Execute("DELETE FROM analytics_cache WHERE cache_key = ?", param.key);
}
else
{
// Clear all cache
deletedCount = connection.Execute("DELETE FROM analytics_cache");
}
return new ClearCacheResult
{
success = true,
deletedCount = deletedCount,
message = $"Cleared {deletedCount} cache entries"
};
}
/// <summary>
/// List all cache entries
/// </summary>
private object HandleListCache(JsonRpcRequest request)
{
if (!DatabaseManager.Instance.IsConnected)
{
throw new Exception("Database is not connected");
}
var connection = DatabaseManager.Instance.Connector?.Connection;
if (connection == null)
{
throw new Exception("Failed to get database connection");
}
var sql = @"
SELECT cache_id, cache_key, expires_at, created_at
FROM analytics_cache
ORDER BY created_at DESC
";
var records = connection.Query<CacheListRecord>(sql);
var entries = records.Select(r => new CacheEntry
{
cacheId = r.cache_id,
cacheKey = r.cache_key,
expiresAt = r.expires_at,
createdAt = r.created_at,
isExpired = IsExpired(r.expires_at)
}).ToList();
return new ListCacheResult
{
success = true,
count = entries.Count,
entries = entries
};
}
#region Helper Methods
private void SetCacheData(SQLite.SQLiteConnection connection, string key, string data, int ttlSeconds)
{
var expiresAt = DateTime.Now.AddSeconds(ttlSeconds).ToString("O");
// Delete existing
connection.Execute("DELETE FROM analytics_cache WHERE cache_key = ?", key);
// Insert new
var sql = @"
INSERT INTO analytics_cache (cache_key, cache_data, expires_at, created_at)
VALUES (?, ?, ?, datetime('now', 'localtime'))
";
connection.Execute(sql, key, data, expiresAt);
}
private string GetCacheData(SQLite.SQLiteConnection connection, string key)
{
var sql = "SELECT cache_data, expires_at FROM analytics_cache WHERE cache_key = ?";
var records = connection.Query<CacheDataRecord>(sql, key);
if (records.Count() == 0)
{
return null;
}
var record = records.First();
// Check expiration
if (IsExpired(record.expires_at))
{
// Delete expired cache
connection.Execute("DELETE FROM analytics_cache WHERE cache_key = ?", key);
return null;
}
return record.cache_data;
}
private bool IsExpired(string expiresAt)
{
if (string.IsNullOrEmpty(expiresAt))
{
return false; // Never expires
}
DateTime expires;
if (DateTime.TryParse(expiresAt, out expires))
{
return DateTime.Now > expires;
}
return false;
}
#endregion
#region Data Classes
private class SceneIdRecord
{
public int scene_id { get; set; }
}
private class ComponentStatRecord
{
public string component_type { get; set; }
public int count { get; set; }
}
private class CacheDataRecord
{
public string cache_data { get; set; }
public string expires_at { get; set; }
}
private class CacheListRecord
{
public int cache_id { get; set; }
public string cache_key { get; set; }
public string expires_at { get; set; }
public string created_at { get; set; }
}
[Serializable]
public class SetCacheParams
{
public string key;
public string data;
public int ttl = 3600; // Default 1 hour
}
[Serializable]
public class GetCacheParams
{
public string key;
}
[Serializable]
public class ClearCacheParams
{
public string key;
}
[Serializable]
public class ComponentStat
{
public string componentType;
public int count;
}
[Serializable]
public class ProjectStatsResult
{
public bool success;
public int totalScenes;
public int totalObjects;
public int totalComponents;
public int totalTransforms;
public int totalSnapshots;
public int commandHistoryCount;
public List<ComponentStat> topComponents;
}
[Serializable]
public class SceneStatsResult
{
public bool success;
public string sceneName;
public string scenePath;
public int sceneId;
public int objectCount;
public int componentCount;
public int snapshotCount;
public int transformHistoryCount;
public string message;
}
[Serializable]
public class CacheResult
{
public bool success;
public string key;
public string message;
}
[Serializable]
public class GetCacheResult
{
public bool success;
public string key;
public string data;
public string message;
}
[Serializable]
public class ClearCacheResult
{
public bool success;
public int deletedCount;
public string message;
}
[Serializable]
public class CacheEntry
{
public int cacheId;
public string cacheKey;
public string expiresAt;
public string createdAt;
public bool isExpired;
}
[Serializable]
public class ListCacheResult
{
public bool success;
public int count;
public List<CacheEntry> entries;
}
#endregion
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,566 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditorToolkit.Protocol;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Animation commands
/// </summary>
public class AnimationHandler : BaseHandler
{
public override string Category => "Animation";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Play":
return HandlePlay(request);
case "Stop":
return HandleStop(request);
case "GetState":
return HandleGetState(request);
case "SetParameter":
return HandleSetParameter(request);
case "GetParameter":
return HandleGetParameter(request);
case "GetParameters":
return HandleGetParameters(request);
case "SetTrigger":
return HandleSetTrigger(request);
case "ResetTrigger":
return HandleResetTrigger(request);
case "CrossFade":
return HandleCrossFade(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
private object HandlePlay(JsonRpcRequest request)
{
var param = ValidateParam<AnimationPlayParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
// Try Animator first (newer system)
var animator = obj.GetComponent<Animator>();
if (animator != null)
{
if (!string.IsNullOrEmpty(param.stateName))
{
animator.Play(param.stateName, param.layer ?? 0, param.normalizedTime ?? 0f);
}
else
{
animator.enabled = true;
animator.speed = param.speed ?? 1f;
}
return new
{
success = true,
gameObject = param.gameObject,
type = "Animator",
stateName = param.stateName ?? "Default",
message = "Animation playing"
};
}
// Fall back to legacy Animation component
var animation = obj.GetComponent<Animation>();
if (animation != null)
{
if (!string.IsNullOrEmpty(param.clipName))
{
animation.Play(param.clipName);
}
else
{
animation.Play();
}
return new
{
success = true,
gameObject = param.gameObject,
type = "Animation",
clipName = param.clipName ?? "Default",
message = "Animation playing"
};
}
throw new Exception($"No Animator or Animation component found on: {param.gameObject}");
}
private object HandleStop(JsonRpcRequest request)
{
var param = ValidateParam<AnimationStopParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
// Try Animator first
var animator = obj.GetComponent<Animator>();
if (animator != null)
{
if (param.resetToDefault)
{
animator.Rebind();
animator.Update(0f);
}
animator.speed = 0f;
return new
{
success = true,
gameObject = param.gameObject,
type = "Animator",
message = "Animation stopped"
};
}
// Fall back to legacy Animation
var animation = obj.GetComponent<Animation>();
if (animation != null)
{
if (!string.IsNullOrEmpty(param.clipName))
{
animation.Stop(param.clipName);
}
else
{
animation.Stop();
}
return new
{
success = true,
gameObject = param.gameObject,
type = "Animation",
message = "Animation stopped"
};
}
throw new Exception($"No Animator or Animation component found on: {param.gameObject}");
}
private object HandleGetState(JsonRpcRequest request)
{
var param = ValidateParam<AnimationStateParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
// Try Animator first
var animator = obj.GetComponent<Animator>();
if (animator != null)
{
int layer = param.layer ?? 0;
var stateInfo = animator.GetCurrentAnimatorStateInfo(layer);
var clipInfo = animator.GetCurrentAnimatorClipInfo(layer);
string currentClipName = "None";
if (clipInfo.Length > 0 && clipInfo[0].clip != null)
{
currentClipName = clipInfo[0].clip.name;
}
return new
{
success = true,
gameObject = param.gameObject,
type = "Animator",
enabled = animator.enabled,
speed = animator.speed,
layer = layer,
currentState = new
{
fullPathHash = stateInfo.fullPathHash,
shortNameHash = stateInfo.shortNameHash,
normalizedTime = stateInfo.normalizedTime,
length = stateInfo.length,
speed = stateInfo.speed,
speedMultiplier = stateInfo.speedMultiplier,
isLooping = stateInfo.loop,
clipName = currentClipName
},
isInTransition = animator.IsInTransition(layer),
hasRootMotion = animator.hasRootMotion,
layerCount = animator.layerCount,
parameterCount = animator.parameterCount
};
}
// Fall back to legacy Animation
var animation = obj.GetComponent<Animation>();
if (animation != null)
{
var clips = new List<object>();
foreach (AnimationState state in animation)
{
clips.Add(new
{
name = state.name,
length = state.length,
normalizedTime = state.normalizedTime,
speed = state.speed,
weight = state.weight,
enabled = state.enabled,
wrapMode = state.wrapMode.ToString()
});
}
return new
{
success = true,
gameObject = param.gameObject,
type = "Animation",
isPlaying = animation.isPlaying,
clipCount = animation.GetClipCount(),
clips = clips
};
}
throw new Exception($"No Animator or Animation component found on: {param.gameObject}");
}
private object HandleSetParameter(JsonRpcRequest request)
{
var param = ValidateParam<AnimationParameterParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var animator = obj.GetComponent<Animator>();
if (animator == null)
{
throw new Exception($"No Animator component found on: {param.gameObject}");
}
// Find parameter type
AnimatorControllerParameterType? paramType = null;
foreach (var p in animator.parameters)
{
if (p.name == param.parameterName)
{
paramType = p.type;
break;
}
}
if (!paramType.HasValue)
{
throw new Exception($"Parameter not found: {param.parameterName}");
}
switch (paramType.Value)
{
case AnimatorControllerParameterType.Float:
animator.SetFloat(param.parameterName, Convert.ToSingle(param.value));
break;
case AnimatorControllerParameterType.Int:
animator.SetInteger(param.parameterName, Convert.ToInt32(param.value));
break;
case AnimatorControllerParameterType.Bool:
animator.SetBool(param.parameterName, Convert.ToBoolean(param.value));
break;
case AnimatorControllerParameterType.Trigger:
if (Convert.ToBoolean(param.value))
animator.SetTrigger(param.parameterName);
else
animator.ResetTrigger(param.parameterName);
break;
}
return new
{
success = true,
gameObject = param.gameObject,
parameterName = param.parameterName,
parameterType = paramType.Value.ToString(),
value = param.value
};
}
private object HandleGetParameter(JsonRpcRequest request)
{
var param = ValidateParam<AnimationGetParameterParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var animator = obj.GetComponent<Animator>();
if (animator == null)
{
throw new Exception($"No Animator component found on: {param.gameObject}");
}
// Find parameter
AnimatorControllerParameter foundParam = null;
foreach (var p in animator.parameters)
{
if (p.name == param.parameterName)
{
foundParam = p;
break;
}
}
if (foundParam == null)
{
throw new Exception($"Parameter not found: {param.parameterName}");
}
object value = null;
switch (foundParam.type)
{
case AnimatorControllerParameterType.Float:
value = animator.GetFloat(param.parameterName);
break;
case AnimatorControllerParameterType.Int:
value = animator.GetInteger(param.parameterName);
break;
case AnimatorControllerParameterType.Bool:
value = animator.GetBool(param.parameterName);
break;
case AnimatorControllerParameterType.Trigger:
value = "Trigger (no value)";
break;
}
return new
{
success = true,
gameObject = param.gameObject,
parameterName = param.parameterName,
parameterType = foundParam.type.ToString(),
value = value
};
}
private object HandleGetParameters(JsonRpcRequest request)
{
var param = ValidateParam<AnimationBaseParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var animator = obj.GetComponent<Animator>();
if (animator == null)
{
throw new Exception($"No Animator component found on: {param.gameObject}");
}
var parameters = new List<object>();
foreach (var p in animator.parameters)
{
object value = null;
switch (p.type)
{
case AnimatorControllerParameterType.Float:
value = animator.GetFloat(p.name);
break;
case AnimatorControllerParameterType.Int:
value = animator.GetInteger(p.name);
break;
case AnimatorControllerParameterType.Bool:
value = animator.GetBool(p.name);
break;
case AnimatorControllerParameterType.Trigger:
value = null;
break;
}
parameters.Add(new
{
name = p.name,
type = p.type.ToString(),
value = value,
defaultFloat = p.defaultFloat,
defaultInt = p.defaultInt,
defaultBool = p.defaultBool
});
}
return new
{
success = true,
gameObject = param.gameObject,
count = parameters.Count,
parameters = parameters
};
}
private object HandleSetTrigger(JsonRpcRequest request)
{
var param = ValidateParam<AnimationTriggerParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var animator = obj.GetComponent<Animator>();
if (animator == null)
{
throw new Exception($"No Animator component found on: {param.gameObject}");
}
animator.SetTrigger(param.triggerName);
return new
{
success = true,
gameObject = param.gameObject,
triggerName = param.triggerName,
message = "Trigger set"
};
}
private object HandleResetTrigger(JsonRpcRequest request)
{
var param = ValidateParam<AnimationTriggerParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var animator = obj.GetComponent<Animator>();
if (animator == null)
{
throw new Exception($"No Animator component found on: {param.gameObject}");
}
animator.ResetTrigger(param.triggerName);
return new
{
success = true,
gameObject = param.gameObject,
triggerName = param.triggerName,
message = "Trigger reset"
};
}
private object HandleCrossFade(JsonRpcRequest request)
{
var param = ValidateParam<AnimationCrossFadeParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var animator = obj.GetComponent<Animator>();
if (animator == null)
{
throw new Exception($"No Animator component found on: {param.gameObject}");
}
animator.CrossFade(
param.stateName,
param.transitionDuration ?? 0.25f,
param.layer ?? 0,
param.normalizedTimeOffset ?? 0f
);
return new
{
success = true,
gameObject = param.gameObject,
stateName = param.stateName,
transitionDuration = param.transitionDuration ?? 0.25f,
message = "CrossFade started"
};
}
// Parameter classes
[Serializable]
public class AnimationBaseParams
{
public string gameObject;
}
[Serializable]
public class AnimationPlayParams : AnimationBaseParams
{
public string stateName; // For Animator
public string clipName; // For legacy Animation
public int? layer;
public float? normalizedTime;
public float? speed;
}
[Serializable]
public class AnimationStopParams : AnimationBaseParams
{
public string clipName;
public bool resetToDefault;
}
[Serializable]
public class AnimationStateParams : AnimationBaseParams
{
public int? layer;
}
[Serializable]
public class AnimationParameterParams : AnimationBaseParams
{
public string parameterName;
public object value;
}
[Serializable]
public class AnimationGetParameterParams : AnimationBaseParams
{
public string parameterName;
}
[Serializable]
public class AnimationTriggerParams : AnimationBaseParams
{
public string triggerName;
}
[Serializable]
public class AnimationCrossFadeParams : AnimationBaseParams
{
public string stateName;
public float? transitionDuration;
public int? layer;
public float? normalizedTimeOffset;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95678540617b4de439b05e447c7b1a8a

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 13f4614dd3790eb418c810975f0eb5cc

View File

@@ -0,0 +1,210 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Base handler for JSON-RPC commands
/// </summary>
public abstract class BaseHandler
{
/// <summary>
/// GameObject 캐시 (WeakReference 사용하여 메모리 누수 방지)
/// </summary>
private static Dictionary<string, System.WeakReference> gameObjectCache = new Dictionary<string, System.WeakReference>();
private static readonly object cacheLock = new object();
/// <summary>
/// Handler category (e.g., "GameObject", "Transform")
/// </summary>
public abstract string Category { get; }
/// <summary>
/// Handle JSON-RPC request
/// </summary>
/// <param name="request">JSON-RPC request</param>
/// <returns>Response object or null for error</returns>
public object Handle(JsonRpcRequest request)
{
try
{
// Validate request
if (request == null)
{
throw new ArgumentNullException(nameof(request), "Request cannot be null");
}
// Validate method name
string fullMethod = request.Method;
if (string.IsNullOrWhiteSpace(fullMethod))
{
throw new ArgumentException("Method name cannot be null or empty", nameof(request.Method));
}
// Validate method belongs to this handler category
if (!fullMethod.StartsWith(Category + "."))
{
throw new ArgumentException($"Invalid method for {Category} handler: {fullMethod}");
}
string methodName = fullMethod.Substring(Category.Length + 1);
// Validate extracted method name
if (string.IsNullOrWhiteSpace(methodName))
{
throw new ArgumentException($"Method name is empty after removing category prefix: {fullMethod}");
}
// Route to specific handler method
return HandleMethod(methodName, request);
}
catch (Exception ex)
{
ToolkitLogger.LogError(Category, $"Handler error: {ex.Message}\n{ex.StackTrace}");
throw;
}
}
/// <summary>
/// Handle specific method (must be implemented by subclass)
/// </summary>
protected abstract object HandleMethod(string method, JsonRpcRequest request);
/// <summary>
/// Validate required parameter
/// </summary>
protected T ValidateParam<T>(JsonRpcRequest request, string paramName) where T : class
{
var paramsObj = request.GetParams<T>();
if (paramsObj == null)
{
throw new Exception($"Missing or invalid parameter: {paramName}");
}
return paramsObj;
}
/// <summary>
/// Find GameObject by name or path (캐싱 적용)
/// </summary>
public UnityEngine.GameObject FindGameObject(string name)
{
if (string.IsNullOrEmpty(name))
{
return null;
}
// 캐시 확인
lock (cacheLock)
{
if (gameObjectCache.TryGetValue(name, out var weakRef) && weakRef.IsAlive)
{
var cachedObj = weakRef.Target as UnityEngine.GameObject;
if (cachedObj != null && cachedObj.scene.IsValid())
{
return cachedObj;
}
else
{
// 캐시 무효화 (객체가 파괴됨)
gameObjectCache.Remove(name);
}
}
}
// Try direct find first (빠른 검색)
var obj = UnityEngine.GameObject.Find(name);
if (obj != null)
{
CacheGameObject(name, obj);
return obj;
}
// Try finding in all objects (including inactive) - 비용이 큼
var allObjects = UnityEngine.Resources.FindObjectsOfTypeAll<UnityEngine.GameObject>();
foreach (var go in allObjects)
{
if (go.name == name || GetGameObjectPath(go) == name)
{
// Make sure it's a scene object, not asset
if (go.scene.IsValid())
{
CacheGameObject(name, go);
return go;
}
}
}
return null;
}
/// <summary>
/// GameObject를 캐시에 추가
/// </summary>
private void CacheGameObject(string name, UnityEngine.GameObject obj)
{
lock (cacheLock)
{
gameObjectCache[name] = new System.WeakReference(obj);
// 캐시 크기 제한 (최대 100개)
if (gameObjectCache.Count > 100)
{
// 만료된(파괴된) 캐시 항목 제거
var toRemove = new List<string>();
foreach (var kvp in gameObjectCache)
{
if (!kvp.Value.IsAlive)
{
toRemove.Add(kvp.Key);
}
}
if (toRemove.Count > 0)
{
foreach (var key in toRemove)
{
gameObjectCache.Remove(key);
}
}
// 여전히 캐시 크기가 100개를 초과하면, 일부 항목을 제거하여 공간 확보
while (gameObjectCache.Count > 100)
{
// 가장 간단한 방법으로 첫 번째 항목 제거
// 더 나은 방법은 LRU(Least Recently Used) 정책을 구현하는 것입니다
var keyToRemove = gameObjectCache.Keys.First();
gameObjectCache.Remove(keyToRemove);
}
}
}
}
/// <summary>
/// 캐시 비우기 (테스트용 또는 메모리 정리)
/// </summary>
public static void ClearCache()
{
lock (cacheLock)
{
gameObjectCache.Clear();
}
}
/// <summary>
/// Get full path of GameObject in hierarchy
/// </summary>
protected string GetGameObjectPath(UnityEngine.GameObject obj)
{
string path = obj.name;
var parent = obj.transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4bd9849f6e036df4787b825d4a713960

View File

@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Editor.Utils;
using Newtonsoft.Json.Linq;
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Chain commands
/// Executes multiple commands sequentially
/// </summary>
public class ChainHandler : BaseHandler
{
public override string Category => "Chain";
private Dictionary<string, BaseHandler> handlers;
public void SetHandlers(Dictionary<string, BaseHandler> handlers)
{
this.handlers = handlers;
}
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Execute":
return HandleExecute(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
private object HandleExecute(JsonRpcRequest request)
{
var param = ValidateParam<ExecuteParams>(request, "commands");
if (param.commands == null || param.commands.Length == 0)
{
throw new Exception("Commands array is required and cannot be empty");
}
ToolkitLogger.Log("ChainHandler", $"Executing {param.commands.Length} command(s) sequentially...");
var results = new List<object>();
double totalElapsed = 0;
for (int i = 0; i < param.commands.Length; i++)
{
var cmd = param.commands[i];
double startTime = UnityEditor.EditorApplication.timeSinceStartup;
try
{
ToolkitLogger.LogDebug("ChainHandler", $"[{i + 1}/{param.commands.Length}] Executing: {cmd.method}");
// Parse method to get category
string category = GetCategory(cmd.method);
if (!handlers.ContainsKey(category))
{
throw new Exception($"Unknown command category: {category}");
}
// Create a new request for this command
var chainedRequest = new JsonRpcRequest
{
Id = $"{request.Id}:chain:{i}",
Method = cmd.method,
Params = cmd.parameters != null ? JToken.FromObject(cmd.parameters) : null
};
// Execute the command
var handler = handlers[category];
var result = handler.Handle(chainedRequest);
double elapsed = UnityEditor.EditorApplication.timeSinceStartup - startTime;
totalElapsed += elapsed;
// If result is null, it's a delayed response (not supported in chain)
if (result == null)
{
throw new Exception($"Command '{cmd.method}' returned delayed response, which is not supported in chain execution");
}
results.Add(new
{
index = i,
method = cmd.method,
success = true,
result = result,
elapsed = elapsed
});
ToolkitLogger.LogDebug("ChainHandler", $"[{i + 1}/{param.commands.Length}] Success: {cmd.method} ({elapsed:F3}s)");
}
catch (Exception ex)
{
double elapsed = UnityEditor.EditorApplication.timeSinceStartup - startTime;
totalElapsed += elapsed;
ToolkitLogger.LogError("ChainHandler", $"[{i + 1}/{param.commands.Length}] Failed: {cmd.method} - {ex.Message}");
// Add error result
results.Add(new
{
index = i,
method = cmd.method,
success = false,
error = ex.Message,
elapsed = elapsed
});
// If stopOnError is true, stop execution
if (param.stopOnError)
{
ToolkitLogger.LogWarning("ChainHandler", "Stopping chain execution due to error (stopOnError=true)");
break;
}
}
}
return new
{
success = true,
totalCommands = param.commands.Length,
executedCommands = results.Count,
totalElapsed = totalElapsed,
results = results
};
}
private string GetCategory(string method)
{
int dotIndex = method.IndexOf('.');
if (dotIndex < 0)
{
throw new Exception($"Invalid method format: {method}");
}
return method.Substring(0, dotIndex);
}
// Parameter classes
[Serializable]
public class ExecuteParams
{
public CommandEntry[] commands;
public bool stopOnError = true;
}
[Serializable]
public class CommandEntry
{
public string method;
public object parameters;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c29fc19def9d9354286806ea825e5c4f

View File

@@ -0,0 +1,973 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditorToolkit.Protocol;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Component commands (add, remove, enable, disable, get, set, inspect, move-up, move-down, copy)
/// </summary>
public class ComponentHandler : BaseHandler
{
public override string Category => "Component";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "List":
return HandleList(request);
case "Add":
return HandleAdd(request);
case "Remove":
return HandleRemove(request);
case "SetEnabled":
return HandleSetEnabled(request);
case "Get":
return HandleGet(request);
case "Set":
return HandleSet(request);
case "Inspect":
return HandleInspect(request);
case "MoveUp":
return HandleMoveUp(request);
case "MoveDown":
return HandleMoveDown(request);
case "Copy":
return HandleCopy(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
/// <summary>
/// List all components on a GameObject
/// </summary>
private object HandleList(JsonRpcRequest request)
{
var param = ValidateParam<ListParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
Component[] components = obj.GetComponents<Component>();
var list = new List<ComponentInfo>();
foreach (var comp in components)
{
if (comp == null) continue;
// Check enabled state (only for Behaviour types)
bool isEnabled = true;
if (comp is Behaviour behaviour)
{
isEnabled = behaviour.enabled;
}
// Skip disabled components if not requested
if (!param.includeDisabled && !isEnabled)
continue;
list.Add(new ComponentInfo
{
type = comp.GetType().Name,
fullTypeName = comp.GetType().FullName,
enabled = isEnabled,
isMonoBehaviour = comp is MonoBehaviour
});
}
return new ComponentListResult { count = list.Count, components = list };
}
/// <summary>
/// Add a component to a GameObject
/// </summary>
private object HandleAdd(JsonRpcRequest request)
{
var param = ValidateParam<AddParams>(request, "name and componentType");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Check if component already exists
if (obj.GetComponent(componentType) != null)
{
throw new Exception($"Component already exists: {param.componentType}");
}
// Add component
Component comp = obj.AddComponent(componentType);
// Register undo
#if UNITY_EDITOR
Undo.RegisterCreatedObjectUndo(comp, "Add Component");
#endif
return new ComponentInfo
{
type = comp.GetType().Name,
fullTypeName = comp.GetType().FullName,
enabled = comp is Behaviour ? ((Behaviour)comp).enabled : true,
isMonoBehaviour = comp is MonoBehaviour
};
}
/// <summary>
/// Remove a component from a GameObject
/// </summary>
private object HandleRemove(JsonRpcRequest request)
{
var param = ValidateParam<RemoveParams>(request, "name and componentType");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Protect Transform
if (componentType == typeof(Transform))
{
throw new Exception("Cannot remove Transform component (required on all GameObjects)");
}
// Find component
Component comp = obj.GetComponent(componentType);
if (comp == null)
{
throw new Exception($"Component not found: {param.componentType}");
}
// Remove component
#if UNITY_EDITOR
Undo.DestroyObjectImmediate(comp);
#else
Object.DestroyImmediate(comp);
#endif
return new { success = true };
}
/// <summary>
/// Enable or disable a component
/// </summary>
private object HandleSetEnabled(JsonRpcRequest request)
{
var param = ValidateParam<SetEnabledParams>(request, "name, componentType and enabled");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Find component
Component comp = obj.GetComponent(componentType);
if (comp == null)
{
throw new Exception($"Component not found: {param.componentType}");
}
// Only Behaviour components support enabled property
if (!(comp is Behaviour behaviour))
{
throw new Exception($"Component {param.componentType} does not support enabled property");
}
#if UNITY_EDITOR
Undo.RegisterCompleteObjectUndo(comp, param.enabled ? "Enable Component" : "Disable Component");
#endif
behaviour.enabled = param.enabled;
return new { success = true, enabled = behaviour.enabled };
}
/// <summary>
/// Get component properties
/// </summary>
private object HandleGet(JsonRpcRequest request)
{
var param = ValidateParam<GetParams>(request, "name and componentType");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Find component
Component comp = obj.GetComponent(componentType);
if (comp == null)
{
throw new Exception($"Component not found: {param.componentType}");
}
#if UNITY_EDITOR
SerializedObject so = new SerializedObject(comp);
// Get specific property
if (!string.IsNullOrEmpty(param.property))
{
SerializedProperty prop = so.FindProperty(param.property);
if (prop == null)
{
throw new Exception($"Property not found: {param.property}");
}
return new PropertyInfo
{
name = param.property,
type = prop.propertyType.ToString(),
value = GetPropertyValue(prop)
};
}
// Get all properties
var properties = new List<PropertyInfo>();
SerializedProperty iterator = so.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = false;
// Skip internal properties
if (iterator.name.StartsWith("m_"))
continue;
properties.Add(new PropertyInfo
{
name = iterator.name,
type = iterator.propertyType.ToString(),
value = GetPropertyValue(iterator)
});
}
return new GetComponentResult
{
componentType = param.componentType,
properties = properties
};
#else
throw new Exception("Component.Get is only available in Editor mode");
#endif
}
/// <summary>
/// Set a component property
/// </summary>
private object HandleSet(JsonRpcRequest request)
{
var param = ValidateParam<SetParams>(request, "name, componentType, property and value");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Find component
Component comp = obj.GetComponent(componentType);
if (comp == null)
{
throw new Exception($"Component not found: {param.componentType}");
}
#if UNITY_EDITOR
SerializedObject so = new SerializedObject(comp);
SerializedProperty prop = so.FindProperty(param.property);
if (prop == null)
{
throw new Exception($"Property not found: {param.property}");
}
// Register undo
Undo.RegisterCompleteObjectUndo(comp, "Set Component Property");
object oldValue = GetPropertyValue(prop);
// Set property value
try
{
SetPropertyValue(prop, param.value);
so.ApplyModifiedProperties();
}
catch (Exception ex)
{
throw new Exception($"Failed to set property: {ex.Message}");
}
return new SetPropertyResult
{
success = true,
property = param.property,
oldValue = oldValue,
newValue = param.value
};
#else
throw new Exception("Component.Set is only available in Editor mode");
#endif
}
/// <summary>
/// Inspect a component (get all properties and state)
/// </summary>
private object HandleInspect(JsonRpcRequest request)
{
var param = ValidateParam<InspectParams>(request, "name and componentType");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Find component
Component comp = obj.GetComponent(componentType);
if (comp == null)
{
throw new Exception($"Component not found: {param.componentType}");
}
#if UNITY_EDITOR
SerializedObject so = new SerializedObject(comp);
var properties = new List<PropertyInfo>();
SerializedProperty iterator = so.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = false;
// Include all properties for inspection
properties.Add(new PropertyInfo
{
name = iterator.name,
type = iterator.propertyType.ToString(),
value = GetPropertyValue(iterator)
});
}
bool isEnabled = comp is Behaviour ? ((Behaviour)comp).enabled : true;
return new InspectComponentResult
{
componentType = param.componentType,
fullTypeName = comp.GetType().FullName,
enabled = isEnabled,
isMonoBehaviour = comp is MonoBehaviour,
properties = properties,
propertyCount = properties.Count
};
#else
throw new Exception("Component.Inspect is only available in Editor mode");
#endif
}
/// <summary>
/// Move a component up in the component list
/// </summary>
private object HandleMoveUp(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<MoveParams>(request, "name and componentType");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Find component
Component comp = obj.GetComponent(componentType);
if (comp == null)
{
throw new Exception($"Component not found: {param.componentType}");
}
// Use ComponentUtility to move component
bool success = UnityEditorInternal.ComponentUtility.MoveComponentUp(comp);
if (!success)
{
throw new Exception("Cannot move component up (already at top or is Transform)");
}
return new { success = true };
#else
throw new Exception("Component.MoveUp is only available in Editor mode");
#endif
}
/// <summary>
/// Move a component down in the component list
/// </summary>
private object HandleMoveDown(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<MoveParams>(request, "name and componentType");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Find component
Component comp = obj.GetComponent(componentType);
if (comp == null)
{
throw new Exception($"Component not found: {param.componentType}");
}
// Use ComponentUtility to move component
bool success = UnityEditorInternal.ComponentUtility.MoveComponentDown(comp);
if (!success)
{
throw new Exception("Cannot move component down (already at bottom)");
}
return new { success = true };
#else
throw new Exception("Component.MoveDown is only available in Editor mode");
#endif
}
/// <summary>
/// Copy a component from one GameObject to another
/// </summary>
private object HandleCopy(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<CopyParams>(request, "source, componentType and target");
var sourceObj = FindGameObject(param.source);
if (sourceObj == null)
{
throw new Exception($"Source GameObject not found: {param.source}");
}
var targetObj = FindGameObject(param.target);
if (targetObj == null)
{
throw new Exception($"Target GameObject not found: {param.target}");
}
// Find component type
Type componentType = FindComponentType(param.componentType);
if (componentType == null)
{
throw new Exception($"Component type not found: {param.componentType}");
}
// Find component on source
Component sourceComp = sourceObj.GetComponent(componentType);
if (sourceComp == null)
{
throw new Exception($"Component not found on source GameObject: {param.componentType}");
}
// Use ComponentUtility to copy component
bool success = UnityEditorInternal.ComponentUtility.CopyComponent(sourceComp);
if (!success)
{
throw new Exception("Failed to copy component");
}
success = UnityEditorInternal.ComponentUtility.PasteComponentAsNew(targetObj);
if (!success)
{
throw new Exception("Failed to paste component");
}
return new { success = true };
#else
throw new Exception("Component.Copy is only available in Editor mode");
#endif
}
/// <summary>
/// Find a component type by name (with multiple fallback strategies)
/// </summary>
private Type FindComponentType(string typeName)
{
if (string.IsNullOrWhiteSpace(typeName))
{
return null;
}
// 1. Try exact type with namespace
var type = Type.GetType(typeName);
if (type != null && typeof(Component).IsAssignableFrom(type))
{
return type;
}
// 2. Try UnityEngine namespace
type = Type.GetType($"UnityEngine.{typeName}, UnityEngine");
if (type != null && typeof(Component).IsAssignableFrom(type))
{
return type;
}
// 3. Scan all assemblies
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
type = assembly.GetType(typeName);
if (type != null && typeof(Component).IsAssignableFrom(type))
{
return type;
}
}
return null;
}
/// <summary>
/// Get the value of a serialized property
/// </summary>
private object GetPropertyValue(SerializedProperty prop)
{
switch (prop.propertyType)
{
case SerializedPropertyType.Integer:
return prop.intValue;
case SerializedPropertyType.Float:
return prop.floatValue;
case SerializedPropertyType.Boolean:
return prop.boolValue;
case SerializedPropertyType.String:
return prop.stringValue;
case SerializedPropertyType.Vector2:
return new { x = prop.vector2Value.x, y = prop.vector2Value.y };
case SerializedPropertyType.Vector3:
return new { x = prop.vector3Value.x, y = prop.vector3Value.y, z = prop.vector3Value.z };
case SerializedPropertyType.Vector4:
return new { x = prop.vector4Value.x, y = prop.vector4Value.y, z = prop.vector4Value.z, w = prop.vector4Value.w };
case SerializedPropertyType.Rect:
return new { x = prop.rectValue.x, y = prop.rectValue.y, width = prop.rectValue.width, height = prop.rectValue.height };
case SerializedPropertyType.ArraySize:
return prop.arraySize;
case SerializedPropertyType.Color:
return new { r = prop.colorValue.r, g = prop.colorValue.g, b = prop.colorValue.b, a = prop.colorValue.a };
case SerializedPropertyType.ObjectReference:
return prop.objectReferenceValue != null ? prop.objectReferenceValue.name : "null";
case SerializedPropertyType.Enum:
return prop.enumValueIndex >= 0 && prop.enumValueIndex < prop.enumNames.Length
? prop.enumNames[prop.enumValueIndex]
: prop.enumValueIndex.ToString();
case SerializedPropertyType.Vector2Int:
return new { x = prop.vector2IntValue.x, y = prop.vector2IntValue.y };
case SerializedPropertyType.Vector3Int:
return new { x = prop.vector3IntValue.x, y = prop.vector3IntValue.y, z = prop.vector3IntValue.z };
case SerializedPropertyType.RectInt:
return new { x = prop.rectIntValue.x, y = prop.rectIntValue.y, width = prop.rectIntValue.width, height = prop.rectIntValue.height };
case SerializedPropertyType.Bounds:
return new {
center = new { x = prop.boundsValue.center.x, y = prop.boundsValue.center.y, z = prop.boundsValue.center.z },
size = new { x = prop.boundsValue.size.x, y = prop.boundsValue.size.y, z = prop.boundsValue.size.z }
};
case SerializedPropertyType.BoundsInt:
return new {
position = new { x = prop.boundsIntValue.position.x, y = prop.boundsIntValue.position.y, z = prop.boundsIntValue.position.z },
size = new { x = prop.boundsIntValue.size.x, y = prop.boundsIntValue.size.y, z = prop.boundsIntValue.size.z }
};
default:
return prop.propertyType.ToString();
}
}
/// <summary>
/// Set the value of a serialized property
/// </summary>
private void SetPropertyValue(SerializedProperty prop, string value)
{
try
{
switch (prop.propertyType)
{
case SerializedPropertyType.Integer:
prop.intValue = int.Parse(value);
break;
case SerializedPropertyType.Float:
prop.floatValue = float.Parse(value);
break;
case SerializedPropertyType.Boolean:
prop.boolValue = bool.Parse(value);
break;
case SerializedPropertyType.String:
prop.stringValue = value;
break;
case SerializedPropertyType.Vector2:
ParseVector2(prop, value);
break;
case SerializedPropertyType.Vector3:
ParseVector3(prop, value);
break;
case SerializedPropertyType.Vector4:
ParseVector4(prop, value);
break;
case SerializedPropertyType.Rect:
ParseRect(prop, value);
break;
case SerializedPropertyType.Color:
ParseColor(prop, value);
break;
case SerializedPropertyType.Enum:
prop.enumValueIndex = System.Array.IndexOf(prop.enumNames, value);
if (prop.enumValueIndex < 0)
{
throw new Exception($"Enum value not found: {value}");
}
break;
case SerializedPropertyType.Vector2Int:
ParseVector2Int(prop, value);
break;
case SerializedPropertyType.Vector3Int:
ParseVector3Int(prop, value);
break;
case SerializedPropertyType.RectInt:
ParseRectInt(prop, value);
break;
case SerializedPropertyType.ObjectReference:
ParseObjectReference(prop, value);
break;
default:
throw new Exception($"Unsupported property type: {prop.propertyType}");
}
}
catch (Exception ex)
{
throw new Exception($"Failed to parse value for {prop.propertyType}: {ex.Message}");
}
}
private void ParseVector2(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length != 2) throw new Exception("Vector2 requires 2 values");
prop.vector2Value = new Vector2(float.Parse(parts[0]), float.Parse(parts[1]));
}
private void ParseVector3(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length != 3) throw new Exception("Vector3 requires 3 values");
prop.vector3Value = new Vector3(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]));
}
private void ParseVector4(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length != 4) throw new Exception("Vector4 requires 4 values");
prop.vector4Value = new Vector4(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]), float.Parse(parts[3]));
}
private void ParseRect(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length != 4) throw new Exception("Rect requires 4 values");
prop.rectValue = new Rect(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]), float.Parse(parts[3]));
}
private void ParseColor(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length < 3 || parts.Length > 4) throw new Exception("Color requires 3-4 values");
float a = parts.Length == 4 ? float.Parse(parts[3]) : 1f;
prop.colorValue = new Color(float.Parse(parts[0]), float.Parse(parts[1]), float.Parse(parts[2]), a);
}
private void ParseVector2Int(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length != 2) throw new Exception("Vector2Int requires 2 values");
prop.vector2IntValue = new Vector2Int(int.Parse(parts[0]), int.Parse(parts[1]));
}
private void ParseVector3Int(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length != 3) throw new Exception("Vector3Int requires 3 values");
prop.vector3IntValue = new Vector3Int(int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]));
}
private void ParseRectInt(SerializedProperty prop, string value)
{
var parts = value.Split(',');
if (parts.Length != 4) throw new Exception("RectInt requires 4 values");
prop.rectIntValue = new RectInt(int.Parse(parts[0]), int.Parse(parts[1]), int.Parse(parts[2]), int.Parse(parts[3]));
}
private void ParseObjectReference(SerializedProperty prop, string value)
{
// Handle null/empty values
if (string.IsNullOrEmpty(value) || value.ToLower() == "null")
{
prop.objectReferenceValue = null;
return;
}
// Check for "GameObject:Component" format (e.g., "GameHUD:UIDocument")
if (value.Contains(":"))
{
var parts = value.Split(':');
if (parts.Length == 2)
{
var goName = parts[0].Trim();
var compTypeName = parts[1].Trim();
var targetGo = FindGameObject(goName);
if (targetGo != null)
{
var compType = FindComponentType(compTypeName);
if (compType != null)
{
var comp = targetGo.GetComponent(compType);
if (comp != null)
{
prop.objectReferenceValue = comp;
return;
}
throw new Exception($"Component '{compTypeName}' not found on GameObject '{goName}'");
}
throw new Exception($"Component type not found: '{compTypeName}'");
}
throw new Exception($"GameObject not found: '{goName}'");
}
}
// Try to find GameObject by name
var foundGameObject = GameObject.Find(value);
if (foundGameObject != null)
{
prop.objectReferenceValue = foundGameObject;
return;
}
// Try to find in all scene objects
var allObjects = Resources.FindObjectsOfTypeAll<GameObject>();
foreach (var obj in allObjects)
{
if (obj.name == value && obj.scene.IsValid())
{
prop.objectReferenceValue = obj;
return;
}
}
// Try to load as asset
#if UNITY_EDITOR
var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(value);
if (asset != null)
{
prop.objectReferenceValue = asset;
return;
}
#endif
// If nothing found, throw error with suggestions
throw new Exception($"ObjectReference not found: '{value}'. Try GameObject name, 'GameObject:Component' format, or asset path.");
}
#region Parameter Classes
[Serializable]
private class ListParams
{
public string name;
public bool includeDisabled;
}
[Serializable]
private class AddParams
{
public string name;
public string componentType;
}
[Serializable]
private class RemoveParams
{
public string name;
public string componentType;
}
[Serializable]
private class SetEnabledParams
{
public string name;
public string componentType;
public bool enabled;
}
[Serializable]
private class GetParams
{
public string name;
public string componentType;
public string property;
}
[Serializable]
private class SetParams
{
public string name;
public string componentType;
public string property;
public string value;
}
[Serializable]
private class InspectParams
{
public string name;
public string componentType;
}
[Serializable]
private class MoveParams
{
public string name;
public string componentType;
}
[Serializable]
private class CopyParams
{
public string source;
public string componentType;
public string target;
}
#endregion
#region Response Classes
[Serializable]
public class ComponentInfo
{
public string type;
public string fullTypeName;
public bool enabled;
public bool isMonoBehaviour;
}
[Serializable]
public class ComponentListResult
{
public int count;
public List<ComponentInfo> components;
}
[Serializable]
public class PropertyInfo
{
public string name;
public string type;
public object value;
}
[Serializable]
public class GetComponentResult
{
public string componentType;
public List<PropertyInfo> properties;
}
[Serializable]
public class SetPropertyResult
{
public bool success;
public string property;
public object oldValue;
public object newValue;
}
[Serializable]
public class InspectComponentResult
{
public string componentType;
public string fullTypeName;
public bool enabled;
public bool isMonoBehaviour;
public List<PropertyInfo> properties;
public int propertyCount;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9596e53f4c5aa9141a05dc71f672b3e7

View File

@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Console commands
/// </summary>
public class ConsoleHandler : BaseHandler
{
public override string Category => "Console";
// Store console logs (Queue로 변경 - O(1) 삽입/삭제)
private static Queue<ConsoleLogEntry> logEntries = new Queue<ConsoleLogEntry>(1000);
private static readonly object logLock = new object();
private static bool isListening = false;
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "GetLogs":
return HandleGetLogs(request);
case "Clear":
return HandleClear(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
public static void StartListening()
{
if (isListening) return;
Application.logMessageReceived += OnLogMessageReceived;
isListening = true;
}
public static void StopListening()
{
if (!isListening) return;
Application.logMessageReceived -= OnLogMessageReceived;
isListening = false;
}
private static void OnLogMessageReceived(string message, string stackTrace, LogType type)
{
lock (logLock)
{
logEntries.Enqueue(new ConsoleLogEntry
{
message = message,
stackTrace = stackTrace,
type = (int)type,
timestamp = DateTime.Now.ToString("HH:mm:ss.fff")
});
// Keep only last 1000 logs (✅ O(1) 연산으로 최적화)
if (logEntries.Count > 1000)
{
logEntries.Dequeue();
}
}
}
private object HandleGetLogs(JsonRpcRequest request)
{
var param = request.GetParams<GetLogsParams>() ?? new GetLogsParams { count = 50 };
var logs = new List<ConsoleLogEntry>();
#if UNITY_EDITOR
// Get logs from Unity Editor Console using Reflection
try
{
var assembly = Assembly.GetAssembly(typeof(UnityEditor.Editor));
if (assembly != null)
{
var logEntriesType = assembly.GetType("UnityEditor.LogEntries");
var logEntryType = assembly.GetType("UnityEditor.LogEntry");
if (logEntriesType != null && logEntryType != null)
{
// Get total count
var getCountMethod = logEntriesType.GetMethod("GetCount", BindingFlags.Static | BindingFlags.Public);
int totalCount = getCountMethod != null ? (int)getCountMethod.Invoke(null, null) : 0;
// Start from the most recent logs
int start = Math.Max(0, totalCount - param.count);
// Get entries
var getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", BindingFlags.Static | BindingFlags.Public);
if (getEntryMethod != null)
{
for (int i = start; i < totalCount; i++)
{
var entry = Activator.CreateInstance(logEntryType);
var parameters = new object[] { i, entry };
getEntryMethod.Invoke(null, parameters);
// Extract fields
var messageField = logEntryType.GetField("message", BindingFlags.Public | BindingFlags.Instance);
var conditionField = logEntryType.GetField("condition", BindingFlags.Public | BindingFlags.Instance);
var modeField = logEntryType.GetField("mode", BindingFlags.Public | BindingFlags.Instance);
string message = conditionField != null ? (string)conditionField.GetValue(entry) : "";
string stackTrace = messageField != null ? (string)messageField.GetValue(entry) : "";
int mode = modeField != null ? (int)modeField.GetValue(entry) : 0;
// If message is empty, use first line of stackTrace as message
if (string.IsNullOrEmpty(message) && !string.IsNullOrEmpty(stackTrace))
{
int firstNewLine = stackTrace.IndexOf('\n');
if (firstNewLine > 0)
{
message = stackTrace.Substring(0, firstNewLine);
}
else
{
message = stackTrace;
}
}
// Convert mode to LogType
LogType logType = ConvertModeToLogType(mode);
// Filter by type
if (param.errorsOnly)
{
if (logType != LogType.Error && logType != LogType.Exception)
continue;
}
else if (!param.includeWarnings)
{
if (logType == LogType.Warning)
continue;
}
logs.Add(new ConsoleLogEntry
{
message = message,
stackTrace = stackTrace,
type = (int)logType,
timestamp = "" // Editor 로그는 실제 발생 시간을 알 수 없음
});
}
}
}
}
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("ConsoleHandler", $"Failed to get Editor console logs: {ex.Message}");
// Fallback to runtime logs
lock (logLock)
{
var logArray = logEntries.ToArray();
int start = Math.Max(0, logArray.Length - param.count);
for (int i = start; i < logArray.Length; i++)
{
var log = logArray[i];
// Filter by type
if (param.errorsOnly)
{
if (log.type != (int)LogType.Error && log.type != (int)LogType.Exception)
continue;
}
else if (!param.includeWarnings)
{
if (log.type == (int)LogType.Warning)
continue;
}
logs.Add(log);
}
}
}
#else
// Runtime: use Application.logMessageReceived logs
lock (logLock)
{
var logArray = logEntries.ToArray();
int start = Math.Max(0, logArray.Length - param.count);
for (int i = start; i < logArray.Length; i++)
{
var log = logArray[i];
if (param.errorsOnly)
{
if (log.type != (int)LogType.Error && log.type != (int)LogType.Exception)
continue;
}
else if (!param.includeWarnings)
{
if (log.type == (int)LogType.Warning)
continue;
}
logs.Add(log);
}
}
#endif
return logs;
}
private LogType ConvertModeToLogType(int mode)
{
// Unity LogEntry mode flags
// Error = 1 << 0 = 1
// Assert = 1 << 1 = 2
// Log = 1 << 2 = 4
// Fatal = 1 << 4 = 16
// DontPreprocessCondition = 1 << 5 = 32
// AssetImportError = 1 << 6 = 64
// AssetImportWarning = 1 << 7 = 128
// ScriptingError = 1 << 8 = 256
// ScriptingWarning = 1 << 9 = 512
// ScriptingLog = 1 << 10 = 1024
// ScriptCompileError = 1 << 11 = 2048
// ScriptCompileWarning = 1 << 12 = 4096
// StickyError = 1 << 13 = 8192
// MayIgnoreLineNumber = 1 << 14 = 16384
// ReportBug = 1 << 15 = 32768
// DisplayPreviousErrorInStatusBar = 1 << 16 = 65536
// ScriptingException = 1 << 17 = 131072
// DontExtractStacktrace = 1 << 18 = 262144
// ShouldClearOnPlay = 1 << 19 = 524288
// GraphCompileError = 1 << 20 = 1048576
// ScriptingAssertion = 1 << 21 = 2097152
// Check error flags
if ((mode & (1 | 64 | 256 | 2048 | 1048576)) != 0)
return LogType.Error;
// Check exception flags
if ((mode & 131072) != 0)
return LogType.Exception;
// Check warning flags
if ((mode & (128 | 512 | 4096)) != 0)
return LogType.Warning;
// Check assert flags
if ((mode & (2 | 2097152)) != 0)
return LogType.Assert;
// Default to Log
return LogType.Log;
}
private object HandleClear(JsonRpcRequest request)
{
lock (logLock)
{
logEntries.Clear();
}
#if UNITY_EDITOR
// Also clear Unity Editor console (✅ Reflection null 체크 추가)
try
{
var assembly = Assembly.GetAssembly(typeof(UnityEditor.Editor));
if (assembly != null)
{
var type = assembly.GetType("UnityEditor.LogEntries");
if (type != null)
{
var method = type.GetMethod("Clear");
if (method != null)
{
method.Invoke(null, null);
}
else
{
ToolkitLogger.LogWarning("ConsoleHandler", "LogEntries.Clear method not found");
}
}
else
{
ToolkitLogger.LogWarning("ConsoleHandler", "UnityEditor.LogEntries type not found");
}
}
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("ConsoleHandler", $"Failed to clear Editor console: {ex.Message}");
}
#endif
return new { success = true };
}
// Parameter classes
[Serializable]
public class GetLogsParams
{
public int count = 50;
public bool errorsOnly = false;
public bool includeWarnings = false;
}
[Serializable]
public class ConsoleLogEntry
{
public string message;
public string stackTrace;
public int type; // LogType: Error=0, Assert=1, Warning=2, Log=3, Exception=4
public string timestamp;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 34faf809a052bfe4c968da086d1fa1de

View File

@@ -0,0 +1,806 @@
using System;
using System.IO;
using UnityEngine;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Editor.Database;
using UnityEditorToolkit.Editor.Utils;
using Cysharp.Threading.Tasks;
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Database command handler
/// SQLite 데이터베이스 관리 명령어
/// </summary>
public class DatabaseHandler : BaseHandler
{
public override string Category => "Database";
// Reset operation state tracking (for async reset via ResponseQueue)
private static bool resetInProgress = false;
private static OperationResult resetResult = null;
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Status":
return HandleStatus();
case "Connect":
return HandleConnect(request);
case "Disconnect":
return HandleDisconnect();
case "Reset":
return HandleReset(request);
case "RunMigrations":
return HandleRunMigrations();
case "ClearMigrations":
return HandleClearMigrations();
case "Undo":
return HandleUndo();
case "Redo":
return HandleRedo();
case "GetHistory":
return HandleGetHistory(request);
case "ClearHistory":
return HandleClearHistory();
case "Query":
return HandleQuery(request);
default:
throw new ArgumentException($"Unknown method: {method}");
}
}
#region Status
private object HandleStatus()
{
var manager = DatabaseManager.Instance;
var health = manager.GetHealthStatus();
return new DatabaseStatusResult
{
isInitialized = health.IsInitialized,
isConnected = health.IsConnected,
isEnabled = health.IsEnabled,
databaseFilePath = health.DatabaseFilePath,
databaseFileExists = health.DatabaseFileExists,
undoCount = manager.CommandHistory?.UndoCount ?? 0,
redoCount = manager.CommandHistory?.RedoCount ?? 0
};
}
#endregion
#region Connect
private class ConnectParams
{
public string databaseFilePath { get; set; }
public bool enableWAL { get; set; } = true;
}
private object HandleConnect(JsonRpcRequest request)
{
if (DatabaseManager.Instance.IsConnected)
{
return new OperationResult
{
success = true,
message = "Already connected"
};
}
var config = DatabaseConfig.LoadFromEditorPrefs();
// Override with request params if provided
if (request.Params != null)
{
var paramsObj = request.GetParams<ConnectParams>();
if (paramsObj != null)
{
if (!string.IsNullOrEmpty(paramsObj.databaseFilePath))
{
config.DatabaseFilePath = paramsObj.databaseFilePath;
}
config.EnableWAL = paramsObj.enableWAL;
}
}
// Synchronous wrapper (blocking call) - Convert UniTask to Task for synchronous execution
var result = DatabaseManager.Instance.InitializeAsync(config).AsTask().GetAwaiter().GetResult();
return new OperationResult
{
success = result.Success,
message = result.Success ? "Connected successfully" : result.ErrorMessage
};
}
#endregion
#region Disconnect
private object HandleDisconnect()
{
if (!DatabaseManager.Instance.IsConnected)
{
return new OperationResult
{
success = true,
message = "Not connected"
};
}
// Synchronous wrapper - Convert UniTask to Task for synchronous execution
DatabaseManager.Instance.ShutdownAsync().AsTask().GetAwaiter().GetResult();
return new OperationResult
{
success = true,
message = "Disconnected successfully"
};
}
#endregion
#region Reset
private object HandleReset(JsonRpcRequest request)
{
// Check if reset is already in progress
if (resetInProgress)
{
return new OperationResult
{
success = false,
message = "Reset operation already in progress"
};
}
// Get send callback from request context
var sendCallback = request.GetContext<Action<string>>("sendCallback");
if (sendCallback == null)
{
throw new Exception("Send callback not found in request context");
}
string requestId = request.Id?.ToString() ?? string.Empty;
// Start async reset operation
resetInProgress = true;
resetResult = null;
// Fire and forget async operation
ResetDatabaseAsync().Forget();
// Register delayed response
ResponseQueue.Instance.Register(
requestId,
condition: () => !resetInProgress,
resultProvider: () => resetResult ?? new OperationResult
{
success = false,
message = "Reset operation failed unexpectedly"
},
sendCallback,
timeoutSeconds: 120.0 // 2 minutes timeout
);
// Return null to indicate delayed response
return null;
}
private async UniTaskVoid ResetDatabaseAsync()
{
try
{
var config = DatabaseConfig.LoadFromEditorPrefs();
string dbPath = config.DatabaseFilePath;
ToolkitLogger.Log("DatabaseHandler", "Starting database reset...");
// Shutdown first (check IsInitialized, not just IsConnected)
if (DatabaseManager.Instance.IsInitialized)
{
try
{
ToolkitLogger.Log("DatabaseHandler", "Shutting down database...");
await DatabaseManager.Instance.ShutdownAsync();
ToolkitLogger.Log("DatabaseHandler", "Database shutdown complete.");
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("DatabaseHandler", $"Shutdown warning: {ex.Message}");
}
}
// Delete database file
bool fileDeleted = false;
if (File.Exists(dbPath))
{
try
{
File.Delete(dbPath);
fileDeleted = true;
ToolkitLogger.Log("DatabaseHandler", $"Database file deleted: {dbPath}");
}
catch (Exception ex)
{
resetResult = new OperationResult
{
success = false,
message = $"Failed to delete database file: {ex.Message}"
};
resetInProgress = false;
return;
}
}
// Reconnect (will run migrations automatically)
ToolkitLogger.Log("DatabaseHandler", "Reconnecting to database...");
var result = await DatabaseManager.Instance.InitializeAsync(config);
resetResult = new OperationResult
{
success = result.Success,
message = result.Success
? $"Database reset successfully. File deleted: {fileDeleted}"
: $"Reset failed: {result.ErrorMessage}"
};
ToolkitLogger.Log("DatabaseHandler", $"Reset complete: {resetResult.message}");
}
catch (Exception ex)
{
ToolkitLogger.LogError("DatabaseHandler", $"Reset exception: {ex.Message}");
resetResult = new OperationResult
{
success = false,
message = $"Reset failed: {ex.Message}"
};
}
finally
{
resetInProgress = false;
}
}
#endregion
#region RunMigrations
private object HandleRunMigrations()
{
if (!DatabaseManager.Instance.IsConnected)
{
return new OperationResult
{
success = false,
message = "Not connected to database"
};
}
var runner = new MigrationRunner(DatabaseManager.Instance);
var result = runner.RunMigrationsAsync().AsTask().GetAwaiter().GetResult();
return new MigrationOperationResult
{
success = result.Success,
message = result.Success
? $"Migrations completed: {result.MigrationsApplied} applied"
: result.ErrorMessage,
migrationsApplied = result.MigrationsApplied
};
}
#endregion
#region ClearMigrations
private object HandleClearMigrations()
{
if (!DatabaseManager.Instance.IsConnected)
{
return new OperationResult
{
success = false,
message = "Not connected to database"
};
}
try
{
var connection = DatabaseManager.Instance.Connector.Connection;
int deleted = connection.Execute("DELETE FROM migrations");
return new OperationResult
{
success = true,
message = $"Cleared {deleted} migration record(s)"
};
}
catch (Exception ex)
{
return new OperationResult
{
success = false,
message = $"Failed to clear migrations: {ex.Message}"
};
}
}
#endregion
#region Undo
private object HandleUndo()
{
if (!DatabaseManager.Instance.IsConnected)
{
return new UndoRedoResult
{
success = false,
message = "Not connected to database",
commandName = "",
remainingUndo = 0,
remainingRedo = 0
};
}
var history = DatabaseManager.Instance.CommandHistory;
if (history == null || history.UndoCount == 0)
{
return new UndoRedoResult
{
success = false,
message = "Nothing to undo",
commandName = "",
remainingUndo = 0,
remainingRedo = history?.RedoCount ?? 0
};
}
try
{
string commandName = history.PeekUndo()?.CommandName ?? "Unknown";
bool result = history.Undo();
return new UndoRedoResult
{
success = result,
message = result ? "Undo successful" : "Undo failed",
commandName = commandName,
remainingUndo = history.UndoCount,
remainingRedo = history.RedoCount
};
}
catch (Exception ex)
{
return new UndoRedoResult
{
success = false,
message = $"Undo failed: {ex.Message}",
commandName = "",
remainingUndo = history.UndoCount,
remainingRedo = history.RedoCount
};
}
}
#endregion
#region Redo
private object HandleRedo()
{
if (!DatabaseManager.Instance.IsConnected)
{
return new UndoRedoResult
{
success = false,
message = "Not connected to database",
commandName = "",
remainingUndo = 0,
remainingRedo = 0
};
}
var history = DatabaseManager.Instance.CommandHistory;
if (history == null || history.RedoCount == 0)
{
return new UndoRedoResult
{
success = false,
message = "Nothing to redo",
commandName = "",
remainingUndo = history?.UndoCount ?? 0,
remainingRedo = 0
};
}
try
{
string commandName = history.PeekRedo()?.CommandName ?? "Unknown";
bool result = history.Redo();
return new UndoRedoResult
{
success = result,
message = result ? "Redo successful" : "Redo failed",
commandName = commandName,
remainingUndo = history.UndoCount,
remainingRedo = history.RedoCount
};
}
catch (Exception ex)
{
return new UndoRedoResult
{
success = false,
message = $"Redo failed: {ex.Message}",
commandName = "",
remainingUndo = history.UndoCount,
remainingRedo = history.RedoCount
};
}
}
#endregion
#region GetHistory
private class GetHistoryParams
{
public int limit { get; set; } = 10;
}
private object HandleGetHistory(JsonRpcRequest request)
{
if (!DatabaseManager.Instance.IsConnected)
{
return new HistoryResult
{
undoStack = new HistoryEntryResult[0],
redoStack = new HistoryEntryResult[0],
totalUndo = 0,
totalRedo = 0
};
}
var history = DatabaseManager.Instance.CommandHistory;
if (history == null)
{
return new HistoryResult
{
undoStack = new HistoryEntryResult[0],
redoStack = new HistoryEntryResult[0],
totalUndo = 0,
totalRedo = 0
};
}
int limit = 10;
if (request.Params != null)
{
var paramsObj = request.GetParams<GetHistoryParams>();
if (paramsObj != null)
{
limit = paramsObj.limit;
}
}
var undoCommands = history.GetUndoStack(limit);
var redoCommands = history.GetRedoStack(limit);
var undoEntries = new HistoryEntryResult[undoCommands.Count];
for (int i = 0; i < undoCommands.Count; i++)
{
var cmd = undoCommands[i];
undoEntries[i] = new HistoryEntryResult
{
name = cmd.CommandName,
timestamp = cmd.ExecutedAt.ToString("yyyy-MM-dd HH:mm:ss"),
canUndo = true
};
}
var redoEntries = new HistoryEntryResult[redoCommands.Count];
for (int i = 0; i < redoCommands.Count; i++)
{
var cmd = redoCommands[i];
redoEntries[i] = new HistoryEntryResult
{
name = cmd.CommandName,
timestamp = cmd.ExecutedAt.ToString("yyyy-MM-dd HH:mm:ss"),
canUndo = false
};
}
return new HistoryResult
{
undoStack = undoEntries,
redoStack = redoEntries,
totalUndo = history.UndoCount,
totalRedo = history.RedoCount
};
}
#endregion
#region ClearHistory
private object HandleClearHistory()
{
if (!DatabaseManager.Instance.IsConnected)
{
return new OperationResult
{
success = false,
message = "Not connected to database"
};
}
var history = DatabaseManager.Instance.CommandHistory;
if (history == null)
{
return new OperationResult
{
success = false,
message = "Command history not available"
};
}
try
{
int undoCount = history.UndoCount;
int redoCount = history.RedoCount;
history.Clear();
return new OperationResult
{
success = true,
message = $"Cleared {undoCount} undo and {redoCount} redo entries"
};
}
catch (Exception ex)
{
return new OperationResult
{
success = false,
message = $"Failed to clear history: {ex.Message}"
};
}
}
#endregion
#region Query
private class QueryParams
{
public string table { get; set; }
public int limit { get; set; } = 100;
}
// Pre-defined table schemas for safe querying
private class MigrationRecord
{
public int migration_id { get; set; }
public string migration_name { get; set; }
public string applied_at { get; set; }
}
private class CommandHistoryQueryRecord
{
public string command_id { get; set; }
public string command_name { get; set; }
public string command_type { get; set; }
public string command_data { get; set; }
public string executed_at { get; set; }
public string executed_by { get; set; }
}
private class TransformQueryRecord
{
public int transform_id { get; set; }
public int object_id { get; set; }
public float position_x { get; set; }
public float position_y { get; set; }
public float position_z { get; set; }
public float rotation_x { get; set; }
public float rotation_y { get; set; }
public float rotation_z { get; set; }
public float rotation_w { get; set; }
public float scale_x { get; set; }
public float scale_y { get; set; }
public float scale_z { get; set; }
public string recorded_at { get; set; }
}
private object HandleQuery(JsonRpcRequest request)
{
if (!DatabaseManager.Instance.IsConnected)
{
return new QueryResult
{
success = false,
message = "Not connected to database",
rows = new object[0],
columns = new string[0],
rowCount = 0
};
}
if (request.Params == null)
{
return new QueryResult
{
success = false,
message = "Table name is required. Supported: migrations, command_history, transforms",
rows = new object[0],
columns = new string[0],
rowCount = 0
};
}
var paramsObj = request.GetParams<QueryParams>();
if (paramsObj == null || string.IsNullOrEmpty(paramsObj.table))
{
return new QueryResult
{
success = false,
message = "Table name is required. Supported: migrations, command_history, transforms",
rows = new object[0],
columns = new string[0],
rowCount = 0
};
}
try
{
var connection = DatabaseManager.Instance.Connector.Connection;
var results = new System.Collections.Generic.List<System.Collections.Generic.Dictionary<string, object>>();
string[] columnNames = null;
string tableName = paramsObj.table.ToLower().Trim();
switch (tableName)
{
case "migrations":
{
columnNames = new[] { "migration_id", "migration_name", "applied_at" };
string sql = $"SELECT migration_id, migration_name, applied_at FROM migrations ORDER BY migration_id DESC LIMIT {paramsObj.limit}";
var records = connection.Query<MigrationRecord>(sql);
foreach (var record in records)
{
var row = new System.Collections.Generic.Dictionary<string, object>
{
["migration_id"] = record.migration_id,
["migration_name"] = record.migration_name,
["applied_at"] = record.applied_at
};
results.Add(row);
}
}
break;
case "command_history":
{
columnNames = new[] { "command_id", "command_name", "command_type", "command_data", "executed_at", "executed_by" };
string sql = $"SELECT command_id, command_name, command_type, command_data, executed_at, executed_by FROM command_history ORDER BY executed_at DESC LIMIT {paramsObj.limit}";
var records = connection.Query<CommandHistoryQueryRecord>(sql);
foreach (var record in records)
{
var row = new System.Collections.Generic.Dictionary<string, object>
{
["command_id"] = record.command_id,
["command_name"] = record.command_name,
["command_type"] = record.command_type,
["command_data"] = record.command_data,
["executed_at"] = record.executed_at,
["executed_by"] = record.executed_by
};
results.Add(row);
}
}
break;
case "transforms":
{
columnNames = new[] { "transform_id", "object_id", "position_x", "position_y", "position_z", "rotation_x", "rotation_y", "rotation_z", "rotation_w", "scale_x", "scale_y", "scale_z", "recorded_at" };
string sql = $"SELECT transform_id, object_id, position_x, position_y, position_z, rotation_x, rotation_y, rotation_z, rotation_w, scale_x, scale_y, scale_z, recorded_at FROM transforms ORDER BY recorded_at DESC LIMIT {paramsObj.limit}";
var records = connection.Query<TransformQueryRecord>(sql);
foreach (var record in records)
{
var row = new System.Collections.Generic.Dictionary<string, object>
{
["transform_id"] = record.transform_id,
["object_id"] = record.object_id,
["position_x"] = record.position_x,
["position_y"] = record.position_y,
["position_z"] = record.position_z,
["rotation_x"] = record.rotation_x,
["rotation_y"] = record.rotation_y,
["rotation_z"] = record.rotation_z,
["rotation_w"] = record.rotation_w,
["scale_x"] = record.scale_x,
["scale_y"] = record.scale_y,
["scale_z"] = record.scale_z,
["recorded_at"] = record.recorded_at
};
results.Add(row);
}
}
break;
default:
return new QueryResult
{
success = false,
message = $"Unknown table: {paramsObj.table}. Supported: migrations, command_history, transforms",
rows = new object[0],
columns = new string[0],
rowCount = 0
};
}
return new QueryResult
{
success = true,
message = $"Query executed successfully. {results.Count} row(s) returned.",
rows = results.ToArray(),
columns = columnNames ?? new string[0],
rowCount = results.Count
};
}
catch (Exception ex)
{
return new QueryResult
{
success = false,
message = $"Query failed: {ex.Message}",
rows = new object[0],
columns = new string[0],
rowCount = 0
};
}
}
#endregion
}
#region Response Types
public class DatabaseStatusResult
{
public bool isInitialized { get; set; }
public bool isConnected { get; set; }
public bool isEnabled { get; set; }
public string databaseFilePath { get; set; }
public bool databaseFileExists { get; set; }
public int undoCount { get; set; }
public int redoCount { get; set; }
}
public class OperationResult
{
public bool success { get; set; }
public string message { get; set; }
}
public class MigrationOperationResult : OperationResult
{
public int migrationsApplied { get; set; }
}
public class UndoRedoResult : OperationResult
{
public string commandName { get; set; }
public int remainingUndo { get; set; }
public int remainingRedo { get; set; }
}
public class HistoryEntryResult
{
public string name { get; set; }
public string timestamp { get; set; }
public bool canUndo { get; set; }
}
public class HistoryResult
{
public HistoryEntryResult[] undoStack { get; set; }
public HistoryEntryResult[] redoStack { get; set; }
public int totalUndo { get; set; }
public int totalRedo { get; set; }
}
public class QueryResult : OperationResult
{
public object[] rows { get; set; }
public string[] columns { get; set; }
public int rowCount { get; set; }
}
#endregion
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 38fbb8a2eff8cc9bee6e8536e40d5062

View File

@@ -0,0 +1,427 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Editor.Attributes;
using UnityEditorToolkit.Editor.Utils;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Editor utility commands
/// </summary>
public class EditorHandler : BaseHandler
{
public override string Category => "Editor";
private static Dictionary<string, MethodInfo> executableMethods;
private static bool isInitialized = false;
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Refresh":
return HandleRefresh(request);
case "Recompile":
return HandleRecompile(request);
case "Reimport":
return HandleReimport(request);
case "GetSelection":
return HandleGetSelection(request);
case "SetSelection":
return HandleSetSelection(request);
case "FocusGameView":
return HandleFocusGameView(request);
case "FocusSceneView":
return HandleFocusSceneView(request);
case "Execute":
return HandleExecute(request);
case "ListExecutable":
return HandleListExecutable(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
private object HandleRefresh(JsonRpcRequest request)
{
#if UNITY_EDITOR
try
{
AssetDatabase.Refresh();
return new { success = true, message = "AssetDatabase refreshed" };
}
catch (Exception ex)
{
throw new Exception($"Failed to refresh AssetDatabase: {ex.Message}");
}
#else
throw new Exception("Refresh is only available in Unity Editor");
#endif
}
private object HandleRecompile(JsonRpcRequest request)
{
#if UNITY_EDITOR
try
{
// Request script compilation
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation();
return new { success = true, message = "Script recompilation requested" };
}
catch (Exception ex)
{
throw new Exception($"Failed to request recompilation: {ex.Message}");
}
#else
throw new Exception("Recompile is only available in Unity Editor");
#endif
}
private object HandleReimport(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<ReimportParams>(request, "path");
try
{
// Build Unity virtual path and physical path
string assetPath = $"Assets/{param.path}";
string physicalPath = System.IO.Path.Combine(Application.dataPath, param.path);
// Validate path exists using physical path
if (!System.IO.File.Exists(physicalPath) && !System.IO.Directory.Exists(physicalPath))
{
throw new Exception($"Asset not found: {assetPath}");
}
// Reimport the asset using Unity virtual path
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
return new { success = true, path = assetPath, message = "Asset reimported" };
}
catch (Exception ex)
{
throw new Exception($"Failed to reimport asset: {ex.Message}");
}
#else
throw new Exception("Reimport is only available in Unity Editor");
#endif
}
private object HandleGetSelection(JsonRpcRequest request)
{
#if UNITY_EDITOR
var activeObject = Selection.activeGameObject;
var selectedObjects = Selection.gameObjects;
if (selectedObjects == null || selectedObjects.Length == 0)
{
return new
{
success = true,
count = 0,
activeObject = (object)null,
selection = new object[0]
};
}
var selectionList = selectedObjects.Select(obj => new
{
name = obj.name,
instanceId = obj.GetInstanceID(),
type = obj.GetType().Name
}).ToList();
return new
{
success = true,
count = selectedObjects.Length,
activeObject = activeObject != null ? new
{
name = activeObject.name,
instanceId = activeObject.GetInstanceID(),
type = activeObject.GetType().Name
} : null,
selection = selectionList
};
#else
throw new Exception("GetSelection is only available in Unity Editor");
#endif
}
private object HandleSetSelection(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<SetSelectionParams>(request, "names");
try
{
var selectedObjects = new List<GameObject>();
var selectedNames = new List<string>();
foreach (var nameOrPath in param.names)
{
GameObject obj = null;
// Try finding by path first (e.g., "Parent/Child/Target")
if (nameOrPath.Contains("/"))
{
obj = GameObject.Find(nameOrPath);
}
// Try finding by name in all scene objects
if (obj == null)
{
var allObjects = UnityEngine.Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None);
obj = allObjects.FirstOrDefault(go => go.name == nameOrPath);
}
if (obj != null)
{
selectedObjects.Add(obj);
selectedNames.Add(obj.name);
}
}
if (selectedObjects.Count > 0)
{
Selection.objects = selectedObjects.ToArray();
}
else
{
Selection.objects = new UnityEngine.Object[0];
}
return new
{
success = true,
selectedCount = selectedObjects.Count,
selectedNames = selectedNames
};
}
catch (Exception ex)
{
throw new Exception($"Failed to set selection: {ex.Message}");
}
#else
throw new Exception("SetSelection is only available in Unity Editor");
#endif
}
private object HandleFocusGameView(JsonRpcRequest request)
{
#if UNITY_EDITOR
try
{
EditorApplication.ExecuteMenuItem("Window/General/Game");
return new { success = true, message = "Game View focused" };
}
catch (Exception ex)
{
throw new Exception($"Failed to focus Game View: {ex.Message}");
}
#else
throw new Exception("FocusGameView is only available in Unity Editor");
#endif
}
private object HandleFocusSceneView(JsonRpcRequest request)
{
#if UNITY_EDITOR
try
{
EditorApplication.ExecuteMenuItem("Window/General/Scene");
return new { success = true, message = "Scene View focused" };
}
catch (Exception ex)
{
throw new Exception($"Failed to focus Scene View: {ex.Message}");
}
#else
throw new Exception("FocusSceneView is only available in Unity Editor");
#endif
}
#if UNITY_EDITOR
private string GetGameObjectPath(GameObject obj)
{
string path = obj.name;
Transform parent = obj.transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
#endif
private void InitializeExecutableMethods()
{
if (isInitialized)
return;
executableMethods = new Dictionary<string, MethodInfo>();
try
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
try
{
var types = assembly.GetTypes();
foreach (var type in types)
{
var methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
foreach (var method in methods)
{
var attribute = method.GetCustomAttribute<ExecutableMethodAttribute>();
if (attribute != null)
{
if (!method.IsStatic)
{
ToolkitLogger.LogWarning("EditorHandler", $"Method {type.FullName}.{method.Name} has [ExecutableMethod] but is not static. Skipping.");
continue;
}
if (method.ReturnType != typeof(void))
{
ToolkitLogger.LogWarning("EditorHandler", $"Method {type.FullName}.{method.Name} has [ExecutableMethod] but does not return void. Skipping.");
continue;
}
if (method.GetParameters().Length > 0)
{
ToolkitLogger.LogWarning("EditorHandler", $"Method {type.FullName}.{method.Name} has [ExecutableMethod] but has parameters. Skipping.");
continue;
}
if (executableMethods.ContainsKey(attribute.CommandName))
{
ToolkitLogger.LogWarning("EditorHandler", $"Duplicate command name '{attribute.CommandName}'. Method {type.FullName}.{method.Name} will override previous registration.");
}
executableMethods[attribute.CommandName] = method;
ToolkitLogger.LogDebug("EditorHandler", $"Registered executable method: '{attribute.CommandName}' -> {type.FullName}.{method.Name}");
}
}
}
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("EditorHandler", $"Failed to scan assembly {assembly.FullName}: {ex.Message}");
}
}
ToolkitLogger.LogDebug("EditorHandler", $"Initialized with {executableMethods.Count} executable methods");
isInitialized = true;
}
catch (Exception ex)
{
ToolkitLogger.LogError("EditorHandler", $"Failed to initialize executable methods: {ex.Message}");
executableMethods = new Dictionary<string, MethodInfo>();
isInitialized = true;
}
}
private object HandleExecute(JsonRpcRequest request)
{
InitializeExecutableMethods();
var param = ValidateParam<ExecuteParams>(request, "commandName");
if (string.IsNullOrWhiteSpace(param.commandName))
{
throw new Exception("Command name is required");
}
if (!executableMethods.TryGetValue(param.commandName, out var methodInfo))
{
throw new Exception($"Unknown command: '{param.commandName}'. Use Editor.ListExecutable to see available commands.");
}
try
{
ToolkitLogger.Log("EditorHandler", $"Executing command: '{param.commandName}'");
methodInfo.Invoke(null, null);
return new
{
success = true,
commandName = param.commandName,
message = $"Command '{param.commandName}' executed successfully"
};
}
catch (TargetInvocationException ex)
{
var innerException = ex.InnerException ?? ex;
ToolkitLogger.LogError("EditorHandler", $"Failed to execute '{param.commandName}': {innerException.Message}\n{innerException.StackTrace}");
throw new Exception($"Failed to execute '{param.commandName}': {innerException.Message}");
}
catch (Exception ex)
{
ToolkitLogger.LogError("EditorHandler", $"Failed to execute '{param.commandName}': {ex.Message}\n{ex.StackTrace}");
throw new Exception($"Failed to execute '{param.commandName}': {ex.Message}");
}
}
private object HandleListExecutable(JsonRpcRequest request)
{
InitializeExecutableMethods();
var methods = executableMethods.Select(kvp =>
{
var methodInfo = kvp.Value;
var attribute = methodInfo.GetCustomAttribute<ExecutableMethodAttribute>();
return new
{
commandName = kvp.Key,
description = attribute?.Description ?? "",
className = methodInfo.DeclaringType?.FullName ?? "Unknown",
methodName = methodInfo.Name
};
}).OrderBy(m => m.commandName).ToList();
return new
{
success = true,
count = methods.Count,
methods = methods
};
}
// Parameter classes
[Serializable]
public class ReimportParams
{
public string path;
}
[Serializable]
public class SetSelectionParams
{
public string[] names;
}
[Serializable]
public class ExecuteParams
{
public string commandName;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ed404892bdf87644915acb29004a870

View File

@@ -0,0 +1,469 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditorToolkit.Protocol;
using UnityEditorToolkit.Editor.Database;
using UnityEditorToolkit.Editor.Database.Commands;
using Cysharp.Threading.Tasks;
using UnityEditorToolkit.Editor.Utils;
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for GameObject commands
/// </summary>
public class GameObjectHandler : BaseHandler
{
public override string Category => "GameObject";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Find":
return HandleFind(request);
case "Create":
return HandleCreate(request);
case "Destroy":
return HandleDestroy(request);
case "SetActive":
return HandleSetActive(request);
case "SetParent":
return HandleSetParent(request);
case "GetParent":
return HandleGetParent(request);
case "GetChildren":
return HandleGetChildren(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
/// <summary>
/// Find GameObject by name or path
/// </summary>
private object HandleFind(JsonRpcRequest request)
{
var param = ValidateParam<FindParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
return new GameObjectInfo
{
name = obj.name,
instanceId = obj.GetInstanceID(),
path = GetGameObjectPath(obj),
active = obj.activeSelf,
tag = obj.tag,
layer = obj.layer
};
}
/// <summary>
/// Create new GameObject
/// </summary>
private object HandleCreate(JsonRpcRequest request)
{
var param = ValidateParam<CreateParams>(request, "name");
// Find parent GameObject if specified
GameObject parentObj = null;
if (!string.IsNullOrEmpty(param.parent))
{
parentObj = FindGameObject(param.parent);
if (parentObj == null)
{
throw new Exception($"Parent GameObject not found: {param.parent}");
}
}
GameObject obj = new GameObject(param.name);
// Set parent if specified
if (parentObj != null)
{
obj.transform.SetParent(parentObj.transform);
}
// Register undo
#if UNITY_EDITOR
UnityEditor.Undo.RegisterCreatedObjectUndo(obj, "Create GameObject");
#endif
// Record Command for history (without re-executing creation)
RecordCreateCommandAsync(obj, parentObj).Forget();
return new GameObjectInfo
{
name = obj.name,
instanceId = obj.GetInstanceID(),
path = GetGameObjectPath(obj),
active = obj.activeSelf,
tag = obj.tag,
layer = obj.layer
};
}
/// <summary>
/// Record CreateGameObjectCommand for history (without re-executing)
/// </summary>
private async UniTaskVoid RecordCreateCommandAsync(GameObject obj, GameObject parent)
{
try
{
#if UNITY_EDITOR
// Check if database is connected
if (DatabaseManager.Instance == null ||
!DatabaseManager.Instance.IsConnected ||
DatabaseManager.Instance.CommandHistory == null)
{
return;
}
// Create command with already created GameObject reference
var command = CreateGameObjectCommand.CreateFromExisting(obj, parent);
// Add to history without executing (already created)
await DatabaseManager.Instance.CommandHistory.RecordCommandAsync(command);
#endif
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("GameObjectHandler", $"Command recording failed: {ex.Message}");
}
}
/// <summary>
/// Destroy GameObject
/// </summary>
private object HandleDestroy(JsonRpcRequest request)
{
var param = ValidateParam<FindParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Execute Command Pattern before actual destruction (database persistence)
ExecuteDeleteCommandAsync(obj).Forget();
#if UNITY_EDITOR
UnityEditor.Undo.DestroyObjectImmediate(obj);
#else
GameObject.DestroyImmediate(obj);
#endif
return new { success = true };
}
/// <summary>
/// Execute DeleteGameObjectCommand asynchronously (database persistence)
/// </summary>
private async UniTaskVoid ExecuteDeleteCommandAsync(GameObject obj)
{
try
{
#if UNITY_EDITOR
// Check if database is connected
if (DatabaseManager.Instance == null ||
!DatabaseManager.Instance.IsConnected ||
DatabaseManager.Instance.CommandHistory == null)
{
return;
}
// Create command
var command = new DeleteGameObjectCommand(obj);
// Execute through CommandHistory (async, database persistence)
// Note: DeleteGameObjectCommand.CanPersist = false (GameObject reference)
// So it will be added to Undo stack but not persisted to database
await DatabaseManager.Instance.CommandHistory.ExecuteCommandAsync(command);
#endif
}
catch (Exception ex)
{
ToolkitLogger.LogWarning("GameObjectHandler", $"Command execution failed: {ex.Message}");
}
}
/// <summary>
/// Set GameObject active state
/// </summary>
private object HandleSetActive(JsonRpcRequest request)
{
var param = ValidateParam<SetActiveParams>(request, "name and active");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
#if UNITY_EDITOR
// ✅ RegisterCompleteObjectUndo 사용 (GameObject 전체 상태 기록)
UnityEditor.Undo.RegisterCompleteObjectUndo(obj, "Set Active");
#endif
obj.SetActive(param.active);
return new { success = true, active = obj.activeSelf };
}
/// <summary>
/// Set or remove parent of GameObject
/// </summary>
private object HandleSetParent(JsonRpcRequest request)
{
var param = ValidateParam<SetParentParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
#if UNITY_EDITOR
UnityEditor.Undo.SetTransformParent(obj.transform, null, "Set Parent");
#endif
// parent가 null이거나 빈 문자열이면 부모 제거
if (string.IsNullOrEmpty(param.parent))
{
obj.transform.SetParent(null, param.worldPositionStays);
return new SetParentResult
{
success = true,
name = obj.name,
parent = null,
path = GetGameObjectPath(obj)
};
}
// 새 부모 찾기
var newParent = FindGameObject(param.parent);
if (newParent == null)
{
throw new Exception($"Parent GameObject not found: {param.parent}");
}
// 순환 참조 체크
if (IsDescendantOf(newParent.transform, obj.transform))
{
throw new Exception($"Cannot set parent: {param.parent} is a descendant of {param.name}");
}
#if UNITY_EDITOR
UnityEditor.Undo.SetTransformParent(obj.transform, newParent.transform, "Set Parent");
#endif
obj.transform.SetParent(newParent.transform, param.worldPositionStays);
return new SetParentResult
{
success = true,
name = obj.name,
parent = newParent.name,
path = GetGameObjectPath(obj)
};
}
/// <summary>
/// Get parent information of GameObject
/// </summary>
private object HandleGetParent(JsonRpcRequest request)
{
var param = ValidateParam<FindParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
var parent = obj.transform.parent;
if (parent == null)
{
return new ParentInfo
{
hasParent = false,
parent = null
};
}
return new ParentInfo
{
hasParent = true,
parent = new GameObjectInfo
{
name = parent.gameObject.name,
instanceId = parent.gameObject.GetInstanceID(),
path = GetGameObjectPath(parent.gameObject),
active = parent.gameObject.activeSelf,
tag = parent.gameObject.tag,
layer = parent.gameObject.layer
}
};
}
/// <summary>
/// Get children of GameObject
/// </summary>
private object HandleGetChildren(JsonRpcRequest request)
{
var param = ValidateParam<GetChildrenParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
var children = new List<GameObjectInfo>();
var transform = obj.transform;
// recursive 옵션에 따라 직접 자식만 또는 모든 자손 반환
if (param.recursive)
{
GetAllDescendants(transform, children);
}
else
{
for (int i = 0; i < transform.childCount; i++)
{
var child = transform.GetChild(i).gameObject;
children.Add(new GameObjectInfo
{
name = child.name,
instanceId = child.GetInstanceID(),
path = GetGameObjectPath(child),
active = child.activeSelf,
tag = child.tag,
layer = child.layer
});
}
}
return new ChildrenInfo
{
count = children.Count,
children = children
};
}
/// <summary>
/// 재귀적으로 모든 자손 가져오기
/// </summary>
private void GetAllDescendants(Transform parent, List<GameObjectInfo> list)
{
for (int i = 0; i < parent.childCount; i++)
{
var child = parent.GetChild(i).gameObject;
list.Add(new GameObjectInfo
{
name = child.name,
instanceId = child.GetInstanceID(),
path = GetGameObjectPath(child),
active = child.activeSelf,
tag = child.tag,
layer = child.layer
});
// 재귀 호출
GetAllDescendants(child.transform, list);
}
}
/// <summary>
/// target이 parent의 자손인지 확인 (순환 참조 방지)
/// </summary>
private bool IsDescendantOf(Transform target, Transform parent)
{
var current = target;
while (current != null)
{
if (current == parent)
return true;
current = current.parent;
}
return false;
}
// Parameter classes (✅ private으로 변경)
[Serializable]
private class FindParams
{
public string name;
}
[Serializable]
private class CreateParams
{
public string name;
public string parent;
}
[Serializable]
private class SetActiveParams
{
public string name;
public bool active;
}
[Serializable]
private class SetParentParams
{
public string name;
public string parent; // null 또는 빈 문자열이면 부모 제거
public bool worldPositionStays = true; // 월드 좌표 유지 여부
}
[Serializable]
private class GetChildrenParams
{
public string name;
public bool recursive = false; // true면 모든 자손, false면 직접 자식만
}
// Response classes
[Serializable]
public class GameObjectInfo
{
public string name;
public int instanceId;
public string path;
public bool active;
public string tag;
public int layer;
}
[Serializable]
public class SetParentResult
{
public bool success;
public string name;
public string parent;
public string path;
}
[Serializable]
public class ParentInfo
{
public bool hasParent;
public GameObjectInfo parent;
}
[Serializable]
public class ChildrenInfo
{
public int count;
public List<GameObjectInfo> children;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 28de8e44386c3eb4085f117c77e4e4a1

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEditorToolkit.Protocol;
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Hierarchy commands
/// </summary>
public class HierarchyHandler : BaseHandler
{
public override string Category => "Hierarchy";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Get":
return HandleGet(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
private object HandleGet(JsonRpcRequest request)
{
var param = request.GetParams<GetParams>() ?? new GetParams();
var rootObjects = new List<GameObjectInfo>();
var scene = SceneManager.GetActiveScene();
if (!scene.IsValid())
{
return rootObjects;
}
foreach (var rootGO in scene.GetRootGameObjects())
{
// Skip inactive if requested
if (!param.includeInactive && !rootGO.activeSelf)
continue;
var info = BuildGameObjectInfo(rootGO, !param.rootOnly, param.includeInactive);
rootObjects.Add(info);
}
return rootObjects;
}
private GameObjectInfo BuildGameObjectInfo(GameObject obj, bool includeChildren, bool includeInactive)
{
var info = new GameObjectInfo
{
name = obj.name,
instanceId = obj.GetInstanceID(),
path = GetGameObjectPath(obj),
active = obj.activeSelf,
tag = obj.tag,
layer = obj.layer
};
if (includeChildren && obj.transform.childCount > 0)
{
info.children = new List<GameObjectInfo>();
for (int i = 0; i < obj.transform.childCount; i++)
{
var child = obj.transform.GetChild(i).gameObject;
// Skip inactive if requested
if (!includeInactive && !child.activeSelf)
continue;
var childInfo = BuildGameObjectInfo(child, true, includeInactive);
info.children.Add(childInfo);
}
}
return info;
}
// Parameter classes
[Serializable]
public class GetParams
{
public bool rootOnly = false;
public bool includeInactive = false;
}
[Serializable]
public class GameObjectInfo
{
public string name;
public int instanceId;
public string path;
public bool active;
public string tag;
public int layer;
public List<GameObjectInfo> children;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c9ef397f870825f4dbeae7e46bc95981

View File

@@ -0,0 +1,618 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditorToolkit.Protocol;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Material commands
/// </summary>
public class MaterialHandler : BaseHandler
{
public override string Category => "Material";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "GetProperty":
return HandleGetProperty(request);
case "SetProperty":
return HandleSetProperty(request);
case "GetColor":
return HandleGetColor(request);
case "SetColor":
return HandleSetColor(request);
case "List":
return HandleList(request);
case "GetShader":
return HandleGetShader(request);
case "SetShader":
return HandleSetShader(request);
case "GetTexture":
return HandleGetTexture(request);
case "SetTexture":
return HandleSetTexture(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
private object HandleGetProperty(JsonRpcRequest request)
{
var param = ValidateParam<MaterialPropertyParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
if (!material.HasProperty(param.propertyName))
{
throw new Exception($"Material does not have property: {param.propertyName}");
}
object value = null;
string propertyType = GetPropertyType(material, param.propertyName);
switch (propertyType)
{
case "Float":
case "Range":
value = material.GetFloat(param.propertyName);
break;
case "Int":
value = material.GetInt(param.propertyName);
break;
case "Color":
var color = material.GetColor(param.propertyName);
value = new { r = color.r, g = color.g, b = color.b, a = color.a };
break;
case "Vector":
var vec = material.GetVector(param.propertyName);
value = new { x = vec.x, y = vec.y, z = vec.z, w = vec.w };
break;
case "Texture":
var tex = material.GetTexture(param.propertyName);
value = tex != null ? new { name = tex.name, type = tex.GetType().Name } : null;
break;
default:
value = "Unknown type";
break;
}
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
propertyName = param.propertyName,
propertyType = propertyType,
value = value
};
}
private object HandleSetProperty(JsonRpcRequest request)
{
var param = ValidateParam<MaterialSetPropertyParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
if (!material.HasProperty(param.propertyName))
{
throw new Exception($"Material does not have property: {param.propertyName}");
}
string propertyType = GetPropertyType(material, param.propertyName);
switch (propertyType)
{
case "Float":
case "Range":
material.SetFloat(param.propertyName, Convert.ToSingle(param.value));
break;
case "Int":
material.SetInt(param.propertyName, Convert.ToInt32(param.value));
break;
default:
throw new Exception($"Property type {propertyType} cannot be set with SetProperty. Use SetColor, SetTexture, or SetVector.");
}
#if UNITY_EDITOR
EditorUtility.SetDirty(material);
#endif
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
propertyName = param.propertyName,
propertyType = propertyType,
value = param.value
};
}
private object HandleGetColor(JsonRpcRequest request)
{
var param = ValidateParam<MaterialColorParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
string propName = string.IsNullOrEmpty(param.propertyName) ? "_Color" : param.propertyName;
if (!material.HasProperty(propName))
{
throw new Exception($"Material does not have color property: {propName}");
}
var color = material.GetColor(propName);
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
propertyName = propName,
color = new
{
r = color.r,
g = color.g,
b = color.b,
a = color.a,
hex = ColorUtility.ToHtmlStringRGBA(color)
}
};
}
private object HandleSetColor(JsonRpcRequest request)
{
var param = ValidateParam<MaterialSetColorParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
string propName = string.IsNullOrEmpty(param.propertyName) ? "_Color" : param.propertyName;
if (!material.HasProperty(propName))
{
throw new Exception($"Material does not have color property: {propName}");
}
Color newColor;
// Parse color from various formats
if (!string.IsNullOrEmpty(param.hex))
{
// Hex format: #RRGGBB or #RRGGBBAA
string hexValue = param.hex.StartsWith("#") ? param.hex : "#" + param.hex;
if (!ColorUtility.TryParseHtmlString(hexValue, out newColor))
{
throw new Exception($"Invalid hex color format: {param.hex}");
}
}
else
{
// RGBA format
newColor = new Color(
param.r ?? 1f,
param.g ?? 1f,
param.b ?? 1f,
param.a ?? 1f
);
}
material.SetColor(propName, newColor);
#if UNITY_EDITOR
EditorUtility.SetDirty(material);
#endif
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
propertyName = propName,
color = new
{
r = newColor.r,
g = newColor.g,
b = newColor.b,
a = newColor.a,
hex = ColorUtility.ToHtmlStringRGBA(newColor)
}
};
}
private object HandleList(JsonRpcRequest request)
{
var param = ValidateParam<MaterialListParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var materials = param.useShared ? renderer.sharedMaterials : renderer.materials;
var materialList = new List<object>();
for (int i = 0; i < materials.Length; i++)
{
var mat = materials[i];
if (mat != null)
{
materialList.Add(new
{
index = i,
name = mat.name,
shader = mat.shader != null ? mat.shader.name : "None"
});
}
}
return new
{
success = true,
gameObject = param.gameObject,
count = materialList.Count,
materials = materialList
};
}
private object HandleGetShader(JsonRpcRequest request)
{
var param = ValidateParam<MaterialBaseParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
var shader = material.shader;
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
shader = shader != null ? new
{
name = shader.name,
propertyCount = shader.GetPropertyCount()
} : null
};
}
private object HandleSetShader(JsonRpcRequest request)
{
var param = ValidateParam<MaterialSetShaderParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
var shader = Shader.Find(param.shaderName);
if (shader == null)
{
throw new Exception($"Shader not found: {param.shaderName}");
}
material.shader = shader;
#if UNITY_EDITOR
EditorUtility.SetDirty(material);
#endif
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
shader = shader.name
};
}
private object HandleGetTexture(JsonRpcRequest request)
{
var param = ValidateParam<MaterialTextureParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
string propName = string.IsNullOrEmpty(param.propertyName) ? "_MainTex" : param.propertyName;
if (!material.HasProperty(propName))
{
throw new Exception($"Material does not have texture property: {propName}");
}
var texture = material.GetTexture(propName);
var scale = material.GetTextureScale(propName);
var offset = material.GetTextureOffset(propName);
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
propertyName = propName,
texture = texture != null ? new
{
name = texture.name,
type = texture.GetType().Name,
width = texture.width,
height = texture.height
} : null,
scale = new { x = scale.x, y = scale.y },
offset = new { x = offset.x, y = offset.y }
};
}
private object HandleSetTexture(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<MaterialSetTextureParams>(request, "gameObject");
var obj = FindGameObject(param.gameObject);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.gameObject}");
}
var renderer = obj.GetComponent<Renderer>();
if (renderer == null)
{
throw new Exception($"No Renderer component found on: {param.gameObject}");
}
var material = GetMaterial(renderer, param.materialIndex, param.useShared);
string propName = string.IsNullOrEmpty(param.propertyName) ? "_MainTex" : param.propertyName;
if (!material.HasProperty(propName))
{
throw new Exception($"Material does not have texture property: {propName}");
}
Texture texture = null;
if (!string.IsNullOrEmpty(param.texturePath))
{
texture = AssetDatabase.LoadAssetAtPath<Texture>(param.texturePath);
if (texture == null)
{
throw new Exception($"Texture not found at path: {param.texturePath}");
}
}
material.SetTexture(propName, texture);
// Set scale and offset if provided
if (param.scaleX.HasValue || param.scaleY.HasValue)
{
var currentScale = material.GetTextureScale(propName);
material.SetTextureScale(propName, new Vector2(
param.scaleX ?? currentScale.x,
param.scaleY ?? currentScale.y
));
}
if (param.offsetX.HasValue || param.offsetY.HasValue)
{
var currentOffset = material.GetTextureOffset(propName);
material.SetTextureOffset(propName, new Vector2(
param.offsetX ?? currentOffset.x,
param.offsetY ?? currentOffset.y
));
}
EditorUtility.SetDirty(material);
return new
{
success = true,
gameObject = param.gameObject,
material = material.name,
propertyName = propName,
texture = texture != null ? texture.name : "None"
};
#else
throw new Exception("SetTexture is only available in Unity Editor");
#endif
}
// Helper methods
private Material GetMaterial(Renderer renderer, int? materialIndex, bool useShared)
{
var materials = useShared ? renderer.sharedMaterials : renderer.materials;
int index = materialIndex ?? 0;
if (index < 0 || index >= materials.Length)
{
throw new Exception($"Material index {index} out of range. Available: 0-{materials.Length - 1}");
}
var material = materials[index];
if (material == null)
{
throw new Exception($"Material at index {index} is null");
}
return material;
}
private string GetPropertyType(Material material, string propertyName)
{
#if UNITY_EDITOR
var shader = material.shader;
int propCount = shader.GetPropertyCount();
for (int i = 0; i < propCount; i++)
{
string name = shader.GetPropertyName(i);
if (name == propertyName)
{
var propType = shader.GetPropertyType(i);
return propType.ToString();
}
}
#endif
return "Unknown";
}
// Parameter classes
[Serializable]
public class MaterialBaseParams
{
public string gameObject;
public int? materialIndex;
public bool useShared;
}
[Serializable]
public class MaterialPropertyParams : MaterialBaseParams
{
public string propertyName;
}
[Serializable]
public class MaterialSetPropertyParams : MaterialPropertyParams
{
public float value;
}
[Serializable]
public class MaterialColorParams : MaterialBaseParams
{
public string propertyName;
}
[Serializable]
public class MaterialSetColorParams : MaterialColorParams
{
public float? r;
public float? g;
public float? b;
public float? a;
public string hex;
}
[Serializable]
public class MaterialListParams
{
public string gameObject;
public bool useShared;
}
[Serializable]
public class MaterialSetShaderParams : MaterialBaseParams
{
public string shaderName;
}
[Serializable]
public class MaterialTextureParams : MaterialBaseParams
{
public string propertyName;
}
[Serializable]
public class MaterialSetTextureParams : MaterialTextureParams
{
public string texturePath;
public float? scaleX;
public float? scaleY;
public float? offsetX;
public float? offsetY;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 39c746341355d8b479e91019101c6a4e

View File

@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
using UnityEditorToolkit.Protocol;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Unity Editor menu commands
/// </summary>
public class MenuHandler : BaseHandler
{
public override string Category => "Menu";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Run":
return HandleRun(request);
case "List":
return HandleList(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
/// <summary>
/// Execute a menu item by path
/// </summary>
private object HandleRun(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<RunParams>(request, "menuPath");
if (string.IsNullOrWhiteSpace(param.menuPath))
{
throw new Exception("Menu path cannot be empty");
}
try
{
bool success = EditorApplication.ExecuteMenuItem(param.menuPath);
if (!success)
{
throw new Exception($"Menu item not found or execution failed: {param.menuPath}");
}
return new
{
success = true,
menuPath = param.menuPath,
message = $"Menu item executed: {param.menuPath}"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to execute menu item '{param.menuPath}': {ex.Message}");
}
#else
throw new Exception("Menu.Run is only available in Unity Editor");
#endif
}
/// <summary>
/// List available menu items
/// </summary>
private object HandleList(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = request.GetParams<ListParams>() ?? new ListParams();
var menus = new List<MenuItemInfo>();
try
{
// Get menu items using reflection (Unity internal API)
var menuType = typeof(EditorApplication).Assembly.GetType("UnityEditor.Menu");
if (menuType != null)
{
// Try to get menu items using internal methods
var getMenuItemsMethod = menuType.GetMethod("GetMenuItems",
BindingFlags.NonPublic | BindingFlags.Static);
if (getMenuItemsMethod != null)
{
// This method exists but parameters vary by Unity version
// Alternative approach: scan common menu paths
menus = GetKnownMenuItems();
}
else
{
// Fallback: return known menu paths
menus = GetKnownMenuItems();
}
}
else
{
menus = GetKnownMenuItems();
}
// Apply filter if provided
if (!string.IsNullOrEmpty(param.filter))
{
string filterLower = param.filter.ToLower();
bool hasWildcard = param.filter.Contains("*");
if (hasWildcard)
{
// Simple wildcard matching
string pattern = filterLower.Replace("*", "");
menus = menus.Where(m =>
{
string pathLower = m.path.ToLower();
if (param.filter.StartsWith("*") && param.filter.EndsWith("*"))
return pathLower.Contains(pattern);
else if (param.filter.StartsWith("*"))
return pathLower.EndsWith(pattern);
else if (param.filter.EndsWith("*"))
return pathLower.StartsWith(pattern);
return pathLower.Contains(pattern);
}).ToList();
}
else
{
menus = menus.Where(m =>
m.path.ToLower().Contains(filterLower)).ToList();
}
}
return new
{
success = true,
menus = menus.OrderBy(m => m.path).ToList(),
count = menus.Count
};
}
catch (Exception ex)
{
throw new Exception($"Failed to list menu items: {ex.Message}");
}
#else
throw new Exception("Menu.List is only available in Unity Editor");
#endif
}
/// <summary>
/// Get known/common menu items
/// </summary>
private List<MenuItemInfo> GetKnownMenuItems()
{
var menus = new List<MenuItemInfo>();
// File menu
AddMenuCategory(menus, "File", new[]
{
"New Scene", "Open Scene", "Save", "Save As...",
"New Project...", "Open Project...", "Save Project",
"Build Settings...", "Build And Run"
});
// Edit menu
AddMenuCategory(menus, "Edit", new[]
{
"Undo", "Redo", "Cut", "Copy", "Paste", "Duplicate", "Delete",
"Select All", "Deselect All", "Select Children", "Select Prefab Root",
"Play", "Pause", "Step",
"Project Settings...", "Preferences...",
"Clear All PlayerPrefs"
});
// Assets menu
AddMenuCategory(menus, "Assets", new[]
{
"Create/Folder", "Create/C# Script", "Create/Shader/Standard Surface Shader",
"Create/Material", "Create/Prefab", "Create/Scene",
"Open", "Delete", "Rename", "Copy Path",
"Import New Asset...", "Import Package/Custom Package...",
"Export Package...", "Find References In Scene",
"Refresh", "Reimport", "Reimport All"
});
// GameObject menu
AddMenuCategory(menus, "GameObject", new[]
{
"Create Empty", "Create Empty Child",
"3D Object/Cube", "3D Object/Sphere", "3D Object/Capsule",
"3D Object/Cylinder", "3D Object/Plane", "3D Object/Quad",
"2D Object/Sprite", "2D Object/Sprite Mask",
"Effects/Particle System", "Effects/Trail", "Effects/Line",
"Light/Directional Light", "Light/Point Light", "Light/Spotlight",
"Audio/Audio Source", "Audio/Audio Reverb Zone",
"Video/Video Player",
"UI/Canvas", "UI/Panel", "UI/Button", "UI/Text", "UI/Image",
"UI/Raw Image", "UI/Slider", "UI/Scrollbar", "UI/Toggle",
"UI/Input Field", "UI/Dropdown", "UI/Scroll View",
"Camera", "Move To View", "Align With View", "Align View to Selected"
});
// Component menu
AddMenuCategory(menus, "Component", new[]
{
"Add...",
"Mesh/Mesh Filter", "Mesh/Mesh Renderer",
"Physics/Rigidbody", "Physics/Box Collider", "Physics/Sphere Collider",
"Physics/Capsule Collider", "Physics/Mesh Collider",
"Physics 2D/Rigidbody 2D", "Physics 2D/Box Collider 2D",
"Rendering/Camera", "Rendering/Light",
"Audio/Audio Source", "Audio/Audio Listener",
"Scripts"
});
// Window menu
AddMenuCategory(menus, "Window", new[]
{
"General/Scene", "General/Game", "General/Inspector", "General/Hierarchy",
"General/Project", "General/Console",
"Animation/Animation", "Animation/Animator",
"Rendering/Lighting", "Rendering/Light Explorer", "Rendering/Occlusion Culling",
"Audio/Audio Mixer",
"Package Manager",
"2D/Tile Palette", "2D/Sprite Editor",
"AI/Navigation",
"Asset Store",
"Layouts/Default", "Layouts/2 by 3", "Layouts/4 Split", "Layouts/Tall", "Layouts/Wide"
});
// Help menu
AddMenuCategory(menus, "Help", new[]
{
"About Unity...", "Unity Manual", "Scripting Reference",
"Unity Forum", "Unity Answers", "Check for Updates"
});
// Tools menu (common items)
AddMenuCategory(menus, "Tools", new[]
{
"Sprite Editor"
});
return menus;
}
private void AddMenuCategory(List<MenuItemInfo> menus, string category, string[] items)
{
foreach (var item in items)
{
string path = item.Contains("/") ? $"{category}/{item}" : $"{category}/{item}";
menus.Add(new MenuItemInfo { path = path, category = category });
}
}
// Parameter classes
[Serializable]
public class RunParams
{
public string menuPath;
}
[Serializable]
public class ListParams
{
public string filter;
}
[Serializable]
public class MenuItemInfo
{
public string path;
public string category;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 46020fd33042fb949bb1096cc5ad6ec2

View File

@@ -0,0 +1,859 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEditorToolkit.Protocol;
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.SceneManagement;
#endif
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for Prefab commands (instantiate, create, unpack, apply, revert, variant, etc.)
/// </summary>
public class PrefabHandler : BaseHandler
{
public override string Category => "Prefab";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "Instantiate":
return HandleInstantiate(request);
case "Create":
return HandleCreate(request);
case "Unpack":
return HandleUnpack(request);
case "Apply":
return HandleApply(request);
case "Revert":
return HandleRevert(request);
case "Variant":
return HandleVariant(request);
case "GetOverrides":
return HandleGetOverrides(request);
case "GetSource":
return HandleGetSource(request);
case "IsInstance":
return HandleIsInstance(request);
case "Open":
return HandleOpen(request);
case "Close":
return HandleClose(request);
case "List":
return HandleList(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
/// <summary>
/// Instantiate a prefab in the scene
/// </summary>
private object HandleInstantiate(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<InstantiateParams>(request, "path");
// Load prefab asset
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(param.path);
if (prefab == null)
{
throw new Exception($"Prefab not found: {param.path}");
}
// Instantiate prefab
GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
if (instance == null)
{
throw new Exception($"Failed to instantiate prefab: {param.path}");
}
// Register undo
Undo.RegisterCreatedObjectUndo(instance, "Instantiate Prefab");
// Set name if provided
if (!string.IsNullOrEmpty(param.name))
{
instance.name = param.name;
}
// Set position if provided
if (!string.IsNullOrEmpty(param.position))
{
var parts = param.position.Split(',');
if (parts.Length == 3)
{
instance.transform.position = new Vector3(
float.Parse(parts[0].Trim()),
float.Parse(parts[1].Trim()),
float.Parse(parts[2].Trim())
);
}
}
// Set rotation if provided
if (!string.IsNullOrEmpty(param.rotation))
{
var parts = param.rotation.Split(',');
if (parts.Length == 3)
{
instance.transform.eulerAngles = new Vector3(
float.Parse(parts[0].Trim()),
float.Parse(parts[1].Trim()),
float.Parse(parts[2].Trim())
);
}
}
// Set parent if provided
if (!string.IsNullOrEmpty(param.parent))
{
var parentObj = FindGameObject(param.parent);
if (parentObj != null)
{
instance.transform.SetParent(parentObj.transform, true);
}
}
return new InstantiateResult
{
success = true,
instanceName = instance.name,
prefabPath = param.path,
position = new Vector3Info(instance.transform.position)
};
#else
throw new Exception("Prefab.Instantiate is only available in Editor mode");
#endif
}
/// <summary>
/// Create a prefab from a scene GameObject
/// </summary>
private object HandleCreate(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<CreateParams>(request, "name and path");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
// Ensure path ends with .prefab
string savePath = param.path;
if (!savePath.EndsWith(".prefab"))
{
savePath += ".prefab";
}
// Create directory if needed
string directory = System.IO.Path.GetDirectoryName(savePath);
if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory))
{
CreateFolderRecursively(directory);
}
// Check if prefab already exists
var existingPrefab = AssetDatabase.LoadAssetAtPath<GameObject>(savePath);
if (existingPrefab != null && !param.overwrite)
{
throw new Exception($"Prefab already exists: {savePath}. Use --overwrite to replace.");
}
// Create prefab
bool success;
GameObject prefab;
if (existingPrefab != null)
{
prefab = PrefabUtility.SaveAsPrefabAssetAndConnect(obj, savePath, InteractionMode.UserAction, out success);
}
else
{
prefab = PrefabUtility.SaveAsPrefabAssetAndConnect(obj, savePath, InteractionMode.UserAction, out success);
}
if (!success || prefab == null)
{
throw new Exception($"Failed to create prefab at: {savePath}");
}
AssetDatabase.Refresh();
return new CreateResult
{
success = true,
prefabPath = savePath,
sourceName = obj.name,
isConnected = PrefabUtility.IsPartOfPrefabInstance(obj)
};
#else
throw new Exception("Prefab.Create is only available in Editor mode");
#endif
}
/// <summary>
/// Unpack a prefab instance
/// </summary>
private object HandleUnpack(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<UnpackParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
{
throw new Exception($"GameObject is not a prefab instance: {param.name}");
}
// Get prefab root
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
if (prefabRoot == null)
{
prefabRoot = obj;
}
Undo.RegisterCompleteObjectUndo(prefabRoot, "Unpack Prefab");
// Unpack based on mode
if (param.completely)
{
PrefabUtility.UnpackPrefabInstance(prefabRoot, PrefabUnpackMode.Completely, InteractionMode.UserAction);
}
else
{
PrefabUtility.UnpackPrefabInstance(prefabRoot, PrefabUnpackMode.OutermostRoot, InteractionMode.UserAction);
}
return new { success = true, unpackedObject = prefabRoot.name, completely = param.completely };
#else
throw new Exception("Prefab.Unpack is only available in Editor mode");
#endif
}
/// <summary>
/// Apply prefab instance overrides to the source prefab
/// </summary>
private object HandleApply(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<ApplyParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
{
throw new Exception($"GameObject is not a prefab instance: {param.name}");
}
// Get prefab root
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
if (prefabRoot == null)
{
prefabRoot = obj;
}
// Get source prefab path
string prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(prefabRoot);
// Apply all overrides
PrefabUtility.ApplyPrefabInstance(prefabRoot, InteractionMode.UserAction);
return new ApplyResult
{
success = true,
instanceName = prefabRoot.name,
prefabPath = prefabPath
};
#else
throw new Exception("Prefab.Apply is only available in Editor mode");
#endif
}
/// <summary>
/// Revert prefab instance overrides
/// </summary>
private object HandleRevert(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<RevertParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
{
throw new Exception($"GameObject is not a prefab instance: {param.name}");
}
// Get prefab root
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
if (prefabRoot == null)
{
prefabRoot = obj;
}
Undo.RegisterCompleteObjectUndo(prefabRoot, "Revert Prefab");
// Revert all overrides
PrefabUtility.RevertPrefabInstance(prefabRoot, InteractionMode.UserAction);
return new { success = true, revertedObject = prefabRoot.name };
#else
throw new Exception("Prefab.Revert is only available in Editor mode");
#endif
}
/// <summary>
/// Create a prefab variant
/// </summary>
private object HandleVariant(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<VariantParams>(request, "sourcePath and variantPath");
// Load source prefab
var sourcePrefab = AssetDatabase.LoadAssetAtPath<GameObject>(param.sourcePath);
if (sourcePrefab == null)
{
throw new Exception($"Source prefab not found: {param.sourcePath}");
}
// Ensure path ends with .prefab
string variantPath = param.variantPath;
if (!variantPath.EndsWith(".prefab"))
{
variantPath += ".prefab";
}
// Create directory if needed
string directory = System.IO.Path.GetDirectoryName(variantPath);
if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory))
{
CreateFolderRecursively(directory);
}
// Instantiate temporarily to create variant
var tempInstance = (GameObject)PrefabUtility.InstantiatePrefab(sourcePrefab);
// Create variant
var variant = PrefabUtility.SaveAsPrefabAsset(tempInstance, variantPath);
// Cleanup temp instance
UnityEngine.Object.DestroyImmediate(tempInstance);
if (variant == null)
{
throw new Exception($"Failed to create variant at: {variantPath}");
}
AssetDatabase.Refresh();
return new VariantResult
{
success = true,
sourcePath = param.sourcePath,
variantPath = variantPath
};
#else
throw new Exception("Prefab.Variant is only available in Editor mode");
#endif
}
/// <summary>
/// Get prefab instance overrides
/// </summary>
private object HandleGetOverrides(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<GetOverridesParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
{
throw new Exception($"GameObject is not a prefab instance: {param.name}");
}
// Get prefab root
var prefabRoot = PrefabUtility.GetOutermostPrefabInstanceRoot(obj);
if (prefabRoot == null)
{
prefabRoot = obj;
}
// Get all overrides
var objectOverrides = PrefabUtility.GetObjectOverrides(prefabRoot);
var addedComponents = PrefabUtility.GetAddedComponents(prefabRoot);
var removedComponents = PrefabUtility.GetRemovedComponents(prefabRoot);
var addedGameObjects = PrefabUtility.GetAddedGameObjects(prefabRoot);
var overrideList = new List<OverrideInfo>();
foreach (var ov in objectOverrides)
{
overrideList.Add(new OverrideInfo
{
type = "PropertyOverride",
targetName = ov.instanceObject?.name ?? "Unknown",
targetType = ov.instanceObject?.GetType().Name ?? "Unknown"
});
}
foreach (var ac in addedComponents)
{
overrideList.Add(new OverrideInfo
{
type = "AddedComponent",
targetName = ac.instanceComponent?.name ?? "Unknown",
targetType = ac.instanceComponent?.GetType().Name ?? "Unknown"
});
}
foreach (var rc in removedComponents)
{
overrideList.Add(new OverrideInfo
{
type = "RemovedComponent",
targetName = rc.assetComponent?.name ?? "Unknown",
targetType = rc.assetComponent?.GetType().Name ?? "Unknown"
});
}
foreach (var ag in addedGameObjects)
{
overrideList.Add(new OverrideInfo
{
type = "AddedGameObject",
targetName = ag.instanceGameObject?.name ?? "Unknown",
targetType = "GameObject"
});
}
return new GetOverridesResult
{
instanceName = prefabRoot.name,
hasOverrides = overrideList.Count > 0,
overrideCount = overrideList.Count,
overrides = overrideList
};
#else
throw new Exception("Prefab.GetOverrides is only available in Editor mode");
#endif
}
/// <summary>
/// Get source prefab path of an instance
/// </summary>
private object HandleGetSource(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<GetSourceParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
if (!PrefabUtility.IsPartOfPrefabInstance(obj))
{
return new GetSourceResult
{
instanceName = obj.name,
isPrefabInstance = false,
prefabPath = null,
prefabType = "None"
};
}
string prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(obj);
var prefabType = PrefabUtility.GetPrefabAssetType(obj);
var prefabStatus = PrefabUtility.GetPrefabInstanceStatus(obj);
return new GetSourceResult
{
instanceName = obj.name,
isPrefabInstance = true,
prefabPath = prefabPath,
prefabType = prefabType.ToString(),
prefabStatus = prefabStatus.ToString()
};
#else
throw new Exception("Prefab.GetSource is only available in Editor mode");
#endif
}
/// <summary>
/// Check if a GameObject is a prefab instance
/// </summary>
private object HandleIsInstance(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<IsInstanceParams>(request, "name");
var obj = FindGameObject(param.name);
if (obj == null)
{
throw new Exception($"GameObject not found: {param.name}");
}
bool isPrefabInstance = PrefabUtility.IsPartOfPrefabInstance(obj);
bool isPrefabAsset = PrefabUtility.IsPartOfPrefabAsset(obj);
bool isOutermostRoot = PrefabUtility.IsOutermostPrefabInstanceRoot(obj);
var prefabType = PrefabUtility.GetPrefabAssetType(obj);
return new IsInstanceResult
{
name = obj.name,
isPrefabInstance = isPrefabInstance,
isPrefabAsset = isPrefabAsset,
isOutermostRoot = isOutermostRoot,
prefabType = prefabType.ToString()
};
#else
throw new Exception("Prefab.IsInstance is only available in Editor mode");
#endif
}
/// <summary>
/// Open a prefab in prefab editing mode
/// </summary>
private object HandleOpen(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<OpenParams>(request, "path");
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(param.path);
if (prefab == null)
{
throw new Exception($"Prefab not found: {param.path}");
}
// Open prefab stage
AssetDatabase.OpenAsset(prefab);
var stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
throw new Exception($"Failed to open prefab: {param.path}");
}
return new OpenResult
{
success = true,
prefabPath = param.path,
prefabName = prefab.name,
stageRoot = stage.prefabContentsRoot?.name
};
#else
throw new Exception("Prefab.Open is only available in Editor mode");
#endif
}
/// <summary>
/// Close prefab editing mode and return to scene
/// </summary>
private object HandleClose(JsonRpcRequest request)
{
#if UNITY_EDITOR
var stage = PrefabStageUtility.GetCurrentPrefabStage();
if (stage == null)
{
return new { success = true, message = "No prefab stage is currently open" };
}
string prefabPath = stage.assetPath;
// Close prefab stage by returning to main stage
StageUtility.GoToMainStage();
return new { success = true, closedPrefab = prefabPath };
#else
throw new Exception("Prefab.Close is only available in Editor mode");
#endif
}
/// <summary>
/// List all prefabs in a folder
/// </summary>
private object HandleList(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<ListParams>(request, "");
string searchPath = string.IsNullOrEmpty(param.path) ? "Assets" : param.path;
// Find all prefab GUIDs
string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { searchPath });
var prefabs = new List<PrefabInfo>();
foreach (var guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
if (prefab != null)
{
var prefabType = PrefabUtility.GetPrefabAssetType(prefab);
prefabs.Add(new PrefabInfo
{
name = prefab.name,
path = assetPath,
type = prefabType.ToString(),
isVariant = prefabType == PrefabAssetType.Variant
});
}
}
return new ListResult
{
count = prefabs.Count,
searchPath = searchPath,
prefabs = prefabs
};
#else
throw new Exception("Prefab.List is only available in Editor mode");
#endif
}
#region Helper Methods
private void CreateFolderRecursively(string path)
{
#if UNITY_EDITOR
if (AssetDatabase.IsValidFolder(path))
return;
string parent = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(parent) && !AssetDatabase.IsValidFolder(parent))
{
CreateFolderRecursively(parent);
}
string folderName = System.IO.Path.GetFileName(path);
AssetDatabase.CreateFolder(parent, folderName);
#endif
}
#endregion
#region Parameter Classes
[Serializable]
private class InstantiateParams
{
public string path;
public string name;
public string position;
public string rotation;
public string parent;
}
[Serializable]
private class CreateParams
{
public string name;
public string path;
public bool overwrite;
}
[Serializable]
private class UnpackParams
{
public string name;
public bool completely;
}
[Serializable]
private class ApplyParams
{
public string name;
}
[Serializable]
private class RevertParams
{
public string name;
}
[Serializable]
private class VariantParams
{
public string sourcePath;
public string variantPath;
}
[Serializable]
private class GetOverridesParams
{
public string name;
}
[Serializable]
private class GetSourceParams
{
public string name;
}
[Serializable]
private class IsInstanceParams
{
public string name;
}
[Serializable]
private class OpenParams
{
public string path;
}
[Serializable]
private class ListParams
{
public string path;
}
#endregion
#region Response Classes
[Serializable]
public class Vector3Info
{
public float x;
public float y;
public float z;
public Vector3Info(Vector3 v)
{
x = v.x;
y = v.y;
z = v.z;
}
}
[Serializable]
public class InstantiateResult
{
public bool success;
public string instanceName;
public string prefabPath;
public Vector3Info position;
}
[Serializable]
public class CreateResult
{
public bool success;
public string prefabPath;
public string sourceName;
public bool isConnected;
}
[Serializable]
public class ApplyResult
{
public bool success;
public string instanceName;
public string prefabPath;
}
[Serializable]
public class VariantResult
{
public bool success;
public string sourcePath;
public string variantPath;
}
[Serializable]
public class OverrideInfo
{
public string type;
public string targetName;
public string targetType;
}
[Serializable]
public class GetOverridesResult
{
public string instanceName;
public bool hasOverrides;
public int overrideCount;
public List<OverrideInfo> overrides;
}
[Serializable]
public class GetSourceResult
{
public string instanceName;
public bool isPrefabInstance;
public string prefabPath;
public string prefabType;
public string prefabStatus;
}
[Serializable]
public class IsInstanceResult
{
public string name;
public bool isPrefabInstance;
public bool isPrefabAsset;
public bool isOutermostRoot;
public string prefabType;
}
[Serializable]
public class OpenResult
{
public bool success;
public string prefabPath;
public string prefabName;
public string stageRoot;
}
[Serializable]
public class PrefabInfo
{
public string name;
public string path;
public string type;
public bool isVariant;
}
[Serializable]
public class ListResult
{
public int count;
public string searchPath;
public List<PrefabInfo> prefabs;
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 37b6a770044b2b248a23949937838244

View File

@@ -0,0 +1,443 @@
using System;
using UnityEngine;
using UnityEditorToolkit.Protocol;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace UnityEditorToolkit.Handlers
{
/// <summary>
/// Handler for EditorPrefs commands
/// </summary>
public class PrefsHandler : BaseHandler
{
public override string Category => "Prefs";
protected override object HandleMethod(string method, JsonRpcRequest request)
{
switch (method)
{
case "GetString":
return HandleGetString(request);
case "GetInt":
return HandleGetInt(request);
case "GetFloat":
return HandleGetFloat(request);
case "GetBool":
return HandleGetBool(request);
case "SetString":
return HandleSetString(request);
case "SetInt":
return HandleSetInt(request);
case "SetFloat":
return HandleSetFloat(request);
case "SetBool":
return HandleSetBool(request);
case "DeleteKey":
return HandleDeleteKey(request);
case "DeleteAll":
return HandleDeleteAll(request);
case "HasKey":
return HandleHasKey(request);
default:
throw new Exception($"Unknown method: {method}");
}
}
private object HandleGetString(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<GetPrefsParams>(request, "key");
try
{
string value = EditorPrefs.GetString(param.key, param.defaultValue ?? "");
return new
{
success = true,
key = param.key,
value = value,
type = "string"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to get EditorPrefs string: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleGetInt(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<GetPrefsParams>(request, "key");
try
{
int defaultValue = 0;
if (param.defaultValue != null)
{
int.TryParse(param.defaultValue, out defaultValue);
}
int value = EditorPrefs.GetInt(param.key, defaultValue);
return new
{
success = true,
key = param.key,
value = value,
type = "int"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to get EditorPrefs int: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleGetFloat(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<GetPrefsParams>(request, "key");
try
{
float defaultValue = 0f;
if (param.defaultValue != null)
{
float.TryParse(param.defaultValue, out defaultValue);
}
float value = EditorPrefs.GetFloat(param.key, defaultValue);
return new
{
success = true,
key = param.key,
value = value,
type = "float"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to get EditorPrefs float: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleGetBool(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<GetPrefsParams>(request, "key");
try
{
bool defaultValue = false;
if (param.defaultValue != null)
{
bool.TryParse(param.defaultValue, out defaultValue);
}
bool value = EditorPrefs.GetBool(param.key, defaultValue);
return new
{
success = true,
key = param.key,
value = value,
type = "bool"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to get EditorPrefs bool: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleSetString(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<SetPrefsParams>(request, "params");
try
{
EditorPrefs.SetString(param.key, param.value);
return new
{
success = true,
key = param.key,
message = "EditorPrefs string value set"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to set EditorPrefs string: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleSetInt(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<SetPrefsParams>(request, "params");
try
{
if (!int.TryParse(param.value, out int intValue))
{
throw new Exception($"Invalid int value: {param.value}");
}
EditorPrefs.SetInt(param.key, intValue);
return new
{
success = true,
key = param.key,
message = "EditorPrefs int value set"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to set EditorPrefs int: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleSetFloat(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<SetPrefsParams>(request, "params");
try
{
if (!float.TryParse(param.value, out float floatValue))
{
throw new Exception($"Invalid float value: {param.value}");
}
EditorPrefs.SetFloat(param.key, floatValue);
return new
{
success = true,
key = param.key,
message = "EditorPrefs float value set"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to set EditorPrefs float: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleSetBool(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<SetPrefsParams>(request, "params");
try
{
if (!bool.TryParse(param.value, out bool boolValue))
{
throw new Exception($"Invalid bool value: {param.value}");
}
EditorPrefs.SetBool(param.key, boolValue);
return new
{
success = true,
key = param.key,
message = "EditorPrefs bool value set"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to set EditorPrefs bool: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleDeleteKey(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<DeleteKeyParams>(request, "key");
try
{
EditorPrefs.DeleteKey(param.key);
return new
{
success = true,
key = param.key,
message = "EditorPrefs key deleted"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to delete EditorPrefs key: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleDeleteAll(JsonRpcRequest request)
{
#if UNITY_EDITOR
try
{
EditorPrefs.DeleteAll();
return new
{
success = true,
message = "All EditorPrefs deleted"
};
}
catch (Exception ex)
{
throw new Exception($"Failed to delete all EditorPrefs: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
private object HandleHasKey(JsonRpcRequest request)
{
#if UNITY_EDITOR
var param = ValidateParam<HasKeyParams>(request, "key");
try
{
bool hasKey = EditorPrefs.HasKey(param.key);
// 키가 존재하면 값과 타입도 함께 반환
if (hasKey)
{
// 타입 감지: int, float, bool, string 순서로 시도
object value = null;
string valueType = "unknown";
// Int 시도
try
{
int intValue = EditorPrefs.GetInt(param.key, int.MinValue);
string strValue = EditorPrefs.GetString(param.key, "");
// Int로 저장된 값이면 문자열 버전이 없거나 숫자 형태
if (string.IsNullOrEmpty(strValue) || int.TryParse(strValue, out _))
{
value = intValue;
valueType = "int";
}
}
catch { }
// Bool 시도 (int로 저장되므로 0 또는 1인지 확인)
if (valueType == "int" && (int)value >= 0 && (int)value <= 1)
{
bool boolValue = EditorPrefs.GetBool(param.key, false);
value = boolValue;
valueType = "bool";
}
// Float 시도
if (valueType == "unknown")
{
try
{
float floatValue = EditorPrefs.GetFloat(param.key, float.MinValue);
if (floatValue != float.MinValue)
{
value = floatValue;
valueType = "float";
}
}
catch { }
}
// String (기본값)
if (valueType == "unknown" || valueType == "int")
{
string strValue = EditorPrefs.GetString(param.key, "");
if (!string.IsNullOrEmpty(strValue))
{
value = strValue;
valueType = "string";
}
}
return new
{
success = true,
key = param.key,
hasKey = true,
type = valueType,
value = value
};
}
else
{
return new
{
success = true,
key = param.key,
hasKey = false
};
}
}
catch (Exception ex)
{
throw new Exception($"Failed to check EditorPrefs key: {ex.Message}");
}
#else
throw new Exception("EditorPrefs is only available in Unity Editor");
#endif
}
// Parameter classes
[Serializable]
public class GetPrefsParams
{
public string key;
public string defaultValue;
}
[Serializable]
public class SetPrefsParams
{
public string key;
public string value;
}
[Serializable]
public class DeleteKeyParams
{
public string key;
}
[Serializable]
public class HasKeyParams
{
public string key;
}
}
}

Some files were not shown because too many files have changed in this diff Show More