refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1,535 @@
//! Build related commands.
//! 构建相关命令。
//!
//! Provides file operations and compilation for build pipelines.
//! 为构建管线提供文件操作和编译功能。
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;
/// Build progress event.
/// 构建进度事件。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildProgressEvent {
/// Progress percentage (0-100) | 进度百分比
pub progress: u32,
/// Current step message | 当前步骤消息
pub message: String,
/// Current step index | 当前步骤索引
pub current_step: u32,
/// Total steps | 总步骤数
pub total_steps: u32,
}
/// Clean and recreate output directory.
/// 清理并重建输出目录。
#[tauri::command]
pub async fn prepare_build_directory(output_path: String) -> Result<(), String> {
let path = Path::new(&output_path);
// Remove existing directory if exists | 如果存在则删除现有目录
if path.exists() {
fs::remove_dir_all(path)
.map_err(|e| format!("Failed to clean output directory | 清理输出目录失败: {}", e))?;
}
// Create fresh directory | 创建新目录
fs::create_dir_all(path)
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
Ok(())
}
/// Copy directory recursively.
/// 递归复制目录。
#[tauri::command]
pub async fn copy_directory(
src: String,
dst: String,
patterns: Option<Vec<String>>,
) -> Result<u32, String> {
let src_path = Path::new(&src);
let dst_path = Path::new(&dst);
if !src_path.exists() {
return Err(format!("Source directory does not exist | 源目录不存在: {}", src));
}
// Create destination directory | 创建目标目录
fs::create_dir_all(dst_path)
.map_err(|e| format!("Failed to create destination directory | 创建目标目录失败: {}", e))?;
let mut copied_count = 0u32;
// Recursively copy | 递归复制
copy_dir_recursive(src_path, dst_path, &patterns, &mut copied_count)?;
Ok(copied_count)
}
/// Helper function to copy directory recursively.
/// 递归复制目录的辅助函数。
fn copy_dir_recursive(
src: &Path,
dst: &Path,
patterns: &Option<Vec<String>>,
count: &mut u32,
) -> Result<(), String> {
for entry in fs::read_dir(src)
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
{
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
let src_path = entry.path();
let file_name = entry.file_name();
let dst_path = dst.join(&file_name);
if src_path.is_dir() {
// Skip hidden directories | 跳过隐藏目录
if file_name.to_string_lossy().starts_with('.') {
continue;
}
fs::create_dir_all(&dst_path)
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
copy_dir_recursive(&src_path, &dst_path, patterns, count)?;
} else {
// Check if file matches patterns | 检查文件是否匹配模式
if let Some(ref pats) = patterns {
let file_name_str = file_name.to_string_lossy();
let matches = pats.iter().any(|p| {
if p.starts_with("*.") {
let ext = &p[2..];
file_name_str.ends_with(&format!(".{}", ext))
} else {
file_name_str.contains(p)
}
});
if !matches {
continue;
}
}
fs::copy(&src_path, &dst_path)
.map_err(|e| format!("Failed to copy file | 复制文件失败: {} -> {}: {}",
src_path.display(), dst_path.display(), e))?;
*count += 1;
}
}
Ok(())
}
/// Bundle options for esbuild.
/// esbuild 打包选项。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BundleOptions {
/// Entry files | 入口文件
pub entry_points: Vec<String>,
/// Output directory | 输出目录
pub output_dir: String,
/// Output format (esm or iife) | 输出格式
pub format: String,
/// Bundle name | 打包名称
pub bundle_name: String,
/// Whether to minify | 是否压缩
pub minify: bool,
/// Whether to generate source map | 是否生成 source map
pub source_map: bool,
/// External dependencies | 外部依赖
pub external: Vec<String>,
/// Project root for resolving imports | 项目根目录
pub project_root: String,
/// Define replacements | 宏定义替换
pub define: Option<std::collections::HashMap<String, String>>,
/// Module alias mappings (e.g., @esengine/ecs-framework -> /path/to/module)
/// 模块别名映射(例如 @esengine/ecs-framework -> /path/to/module
pub alias: Option<std::collections::HashMap<String, String>>,
/// Global name for IIFE format (assigns exports to window.{globalName})
/// IIFE 格式的全局变量名(将导出赋值给 window.{globalName}
pub global_name: Option<String>,
/// Files to inject at the start of bundle (esbuild --inject)
/// 在打包开始时注入的文件esbuild --inject
pub inject: Option<Vec<String>>,
/// Banner code to prepend to bundle
/// 添加到打包文件开头的代码
pub banner: Option<String>,
}
/// Bundle result.
/// 打包结果。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BundleResult {
/// Whether bundling succeeded | 是否打包成功
pub success: bool,
/// Output file path | 输出文件路径
pub output_file: Option<String>,
/// Output file size in bytes | 输出文件大小(字节)
pub output_size: Option<u64>,
/// Error message if failed | 失败时的错误信息
pub error: Option<String>,
/// Warnings | 警告
pub warnings: Vec<String>,
}
/// Bundle JavaScript/TypeScript files using esbuild.
/// 使用 esbuild 打包 JavaScript/TypeScript 文件。
#[tauri::command]
pub async fn bundle_scripts(options: BundleOptions) -> Result<BundleResult, String> {
let esbuild_path = find_esbuild(&options.project_root)?;
// Build output file path | 构建输出文件路径
// Note: Don't use .with_extension() as it replaces the last dot-segment
// 注意:不要使用 .with_extension(),因为它会替换最后一个点分段
// e.g., "esengine.core" would become "esengine.js" instead of "esengine.core.js"
let output_file = Path::new(&options.output_dir)
.join(format!("{}.js", &options.bundle_name));
// Ensure output directory exists | 确保输出目录存在
if let Some(parent) = output_file.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create output directory | 创建输出目录失败: {}", e))?;
}
// Build esbuild arguments | 构建 esbuild 参数
let mut args: Vec<String> = options.entry_points.clone();
args.push("--bundle".to_string());
args.push(format!("--outfile={}", output_file.display()));
args.push(format!("--format={}", options.format));
args.push("--platform=browser".to_string());
args.push("--target=es2020".to_string());
// Show detailed warnings instead of just count
// 显示详细警告而不仅仅是数量
args.push("--log-level=warning".to_string());
if options.source_map {
args.push("--sourcemap".to_string());
}
if options.minify {
args.push("--minify".to_string());
}
for external in &options.external {
args.push(format!("--external:{}", external));
}
// Add define replacements | 添加宏定义替换
if let Some(ref defines) = options.define {
for (key, value) in defines {
args.push(format!("--define:{}={}", key, value));
}
}
// Add alias mappings | 添加别名映射
if let Some(ref aliases) = options.alias {
for (from, to) in aliases {
args.push(format!("--alias:{}={}", from, to));
}
}
// Add global name for IIFE format | 为 IIFE 格式添加全局变量名
if let Some(ref global_name) = options.global_name {
args.push(format!("--global-name={}", global_name));
}
// Add inject files | 添加注入文件
if let Some(ref inject_files) = options.inject {
for file in inject_files {
args.push(format!("--inject:{}", file));
}
}
// Add banner | 添加 banner
if let Some(ref banner) = options.banner {
args.push(format!("--banner:js={}", banner));
}
// Log esbuild command for debugging
println!("[esbuild] bundle_name: {}", options.bundle_name);
println!("[esbuild] format: {}", options.format);
println!("[esbuild] output_file: {}", output_file.display());
println!("[esbuild] entry_points: {:?}", options.entry_points);
println!("[esbuild] args: {:?}", args);
// Run esbuild | 运行 esbuild
let output = Command::new(&esbuild_path)
.args(&args)
.current_dir(&options.project_root)
.output()
.map_err(|e| format!("Failed to run esbuild | 运行 esbuild 失败: {}", e))?;
if output.status.success() {
// Get output file size | 获取输出文件大小
let output_size = fs::metadata(&output_file)
.map(|m| m.len())
.ok();
// Parse warnings from stderr | 从 stderr 解析警告
// esbuild outputs warnings to stderr even on success
// esbuild 即使成功也会将警告输出到 stderr
let stderr = String::from_utf8_lossy(&output.stderr);
let mut warnings: Vec<String> = Vec::new();
if !stderr.is_empty() {
println!("[esbuild] stderr output:\n{}", stderr);
// Collect all non-empty lines as warnings
// esbuild warning format varies, so collect everything
// 收集所有非空行作为警告,因为 esbuild 警告格式多变
for line in stderr.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
warnings.push(trimmed.to_string());
}
}
}
Ok(BundleResult {
success: true,
output_file: Some(output_file.to_string_lossy().to_string()),
output_size,
error: None,
warnings,
})
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(BundleResult {
success: false,
output_file: None,
output_size: None,
error: Some(stderr.to_string()),
warnings: vec![],
})
}
}
/// Generate HTML file from template.
/// 从模板生成 HTML 文件。
#[tauri::command]
pub async fn generate_html(
output_path: String,
title: String,
scripts: Vec<String>,
body_content: Option<String>,
) -> Result<(), String> {
let scripts_html: String = scripts
.iter()
.map(|s| format!(r#" <script src="{}"></script>"#, s))
.collect::<Vec<_>>()
.join("\n");
let body = body_content.unwrap_or_else(|| {
r#" <canvas id="game-canvas" style="width: 100%; height: 100%;"></canvas>"#.to_string()
});
let html = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
html, body {{ width: 100%; height: 100%; overflow: hidden; background: #000; }}
</style>
</head>
<body>
{}
{}
</body>
</html>"#,
title, body, scripts_html
);
// Ensure parent directory exists | 确保父目录存在
let path = Path::new(&output_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
}
fs::write(&output_path, html)
.map_err(|e| format!("Failed to write HTML file | 写入 HTML 文件失败: {}", e))?;
Ok(())
}
/// Get file size.
/// 获取文件大小。
#[tauri::command]
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
fs::metadata(&file_path)
.map(|m| m.len())
.map_err(|e| format!("Failed to get file size | 获取文件大小失败: {}", e))
}
/// Get directory size recursively.
/// 递归获取目录大小。
#[tauri::command]
pub async fn get_directory_size(dir_path: String) -> Result<u64, String> {
let path = Path::new(&dir_path);
if !path.exists() {
return Err(format!("Directory does not exist | 目录不存在: {}", dir_path));
}
calculate_dir_size(path)
}
/// Helper to calculate directory size.
/// 计算目录大小的辅助函数。
fn calculate_dir_size(path: &Path) -> Result<u64, String> {
let mut total_size = 0u64;
for entry in fs::read_dir(path)
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
{
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
let entry_path = entry.path();
if entry_path.is_dir() {
total_size += calculate_dir_size(&entry_path)?;
} else {
total_size += fs::metadata(&entry_path)
.map(|m| m.len())
.unwrap_or(0);
}
}
Ok(total_size)
}
/// Find esbuild executable.
/// 查找 esbuild 可执行文件。
fn find_esbuild(project_root: &str) -> Result<String, String> {
let project_path = Path::new(project_root);
// Try local node_modules first | 首先尝试本地 node_modules
let local_esbuild = if cfg!(windows) {
project_path.join("node_modules/.bin/esbuild.cmd")
} else {
project_path.join("node_modules/.bin/esbuild")
};
if local_esbuild.exists() {
return Ok(local_esbuild.to_string_lossy().to_string());
}
// Try global esbuild | 尝试全局 esbuild
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
let check = Command::new(global_esbuild)
.arg("--version")
.output();
match check {
Ok(output) if output.status.success() => Ok(global_esbuild.to_string()),
_ => Err("esbuild not found | 未找到 esbuild".to_string())
}
}
/// Write JSON file.
/// 写入 JSON 文件。
#[tauri::command]
pub async fn write_json_file(file_path: String, content: String) -> Result<(), String> {
let path = Path::new(&file_path);
// Ensure parent directory exists | 确保父目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory | 创建目录失败: {}", e))?;
}
fs::write(&file_path, content)
.map_err(|e| format!("Failed to write JSON file | 写入 JSON 文件失败: {}", e))?;
Ok(())
}
/// List files in directory with extension filter.
/// 列出目录中指定扩展名的文件。
#[tauri::command]
pub async fn list_files_by_extension(
dir_path: String,
extensions: Vec<String>,
recursive: bool,
) -> Result<Vec<String>, String> {
let path = Path::new(&dir_path);
if !path.exists() {
return Ok(vec![]);
}
let mut files = Vec::new();
list_files_recursive(path, &extensions, recursive, &mut files)?;
Ok(files)
}
/// Helper to list files recursively.
/// 递归列出文件的辅助函数。
fn list_files_recursive(
path: &Path,
extensions: &[String],
recursive: bool,
files: &mut Vec<String>,
) -> Result<(), String> {
for entry in fs::read_dir(path)
.map_err(|e| format!("Failed to read directory | 读取目录失败: {}", e))?
{
let entry = entry.map_err(|e| format!("Failed to read entry | 读取条目失败: {}", e))?;
let entry_path = entry.path();
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
if entry_path.is_dir() {
// Skip node_modules, hidden directories, and other large directories
// 跳过 node_modules、隐藏目录和其他大型目录
if file_name_str.starts_with('.')
|| file_name_str == "node_modules"
|| file_name_str == "target"
|| file_name_str == ".git"
{
continue;
}
if recursive {
list_files_recursive(&entry_path, extensions, recursive, files)?;
}
} else if let Some(ext) = entry_path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.iter().any(|e| e.to_lowercase() == ext_str) {
files.push(entry_path.to_string_lossy().to_string());
}
}
}
Ok(())
}
/// Read binary file and return as base64.
/// 读取二进制文件并返回 base64 编码。
#[tauri::command]
pub async fn read_binary_file_as_base64(path: String) -> Result<String, String> {
use base64::{Engine as _, engine::general_purpose::STANDARD};
let bytes = fs::read(&path)
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))?;
Ok(STANDARD.encode(&bytes))
}
/// Read binary file and return as raw bytes.
/// 读取二进制文件并返回原始字节。
#[tauri::command]
pub async fn read_binary_file(file_path: String) -> Result<Vec<u8>, String> {
fs::read(&file_path)
.map_err(|e| format!("Failed to read binary file | 读取二进制文件失败: {}", e))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
//! Dialog operations
//!
//! Generic system dialog commands for file/folder selection.
//! No business-specific logic - all filtering is done via parameters.
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
/// File filter definition
#[derive(serde::Deserialize)]
pub struct FileFilter {
pub name: String,
pub extensions: Vec<String>,
}
/// Open folder selection dialog
#[tauri::command]
pub async fn open_folder_dialog(
app: AppHandle,
title: Option<String>,
) -> Result<Option<String>, String> {
let mut dialog = app.dialog().file();
if let Some(t) = title {
dialog = dialog.set_title(&t);
} else {
dialog = dialog.set_title("Select Folder");
}
let folder = dialog.blocking_pick_folder();
Ok(folder.map(|path| path.to_string()))
}
/// Open file selection dialog (generic)
#[tauri::command]
pub async fn open_file_dialog(
app: AppHandle,
title: Option<String>,
filters: Option<Vec<FileFilter>>,
multiple: Option<bool>,
) -> Result<Option<Vec<String>>, String> {
let mut dialog = app.dialog().file();
if let Some(t) = title {
dialog = dialog.set_title(&t);
} else {
dialog = dialog.set_title("Select File");
}
if let Some(filter_list) = filters {
for filter in filter_list {
let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
dialog = dialog.add_filter(&filter.name, &extensions);
}
}
if multiple.unwrap_or(false) {
let files = dialog.blocking_pick_files();
Ok(files.map(|paths| paths.iter().map(|p| p.to_string()).collect()))
} else {
let file = dialog.blocking_pick_file();
Ok(file.map(|path| vec![path.to_string()]))
}
}
/// Save file dialog (generic)
/// 通用保存文件对话框
#[tauri::command]
pub async fn save_file_dialog(
app: AppHandle,
title: Option<String>,
default_name: Option<String>,
default_path: Option<String>,
filters: Option<Vec<FileFilter>>,
) -> Result<Option<String>, String> {
let mut dialog = app.dialog().file();
if let Some(t) = title {
dialog = dialog.set_title(&t);
} else {
dialog = dialog.set_title("Save File");
}
// Set default directory | 设置默认目录
if let Some(path) = default_path {
let path_buf = std::path::PathBuf::from(&path);
if path_buf.exists() {
dialog = dialog.set_directory(&path_buf);
}
}
if let Some(name) = default_name {
dialog = dialog.set_file_name(&name);
}
if let Some(filter_list) = filters {
for filter in filter_list {
let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
dialog = dialog.add_filter(&filter.name, &extensions);
}
}
let file = dialog.blocking_save_file();
Ok(file.map(|path| path.to_string()))
}

View File

@@ -0,0 +1,291 @@
//! Generic file system operations
//!
//! Provides low-level file system commands that can be composed by the frontend
//! for business logic. No business-specific logic should be in this module.
use std::fs;
use std::path::Path;
/// Directory entry information
#[derive(serde::Serialize)]
pub struct DirectoryEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: Option<u64>,
pub modified: Option<u64>,
}
/// Read text file content
#[tauri::command]
pub fn read_file_content(path: String) -> Result<String, String> {
fs::read_to_string(&path)
.map_err(|e| format!("Failed to read file {}: {}", path, e))
}
/// Append text to log file (auto-creates parent directories)
/// 追加文本到日志文件(自动创建父目录)
#[tauri::command]
pub fn append_to_log(path: String, content: String) -> Result<(), String> {
use std::fs::OpenOptions;
use std::io::Write;
// Ensure parent directory exists
if let Some(parent) = Path::new(&path).parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("Failed to open log file {}: {}", path, e))?;
writeln!(file, "{}", content)
.map_err(|e| format!("Failed to write to log file {}: {}", path, e))
}
/// Write text content to file (auto-creates parent directories)
#[tauri::command]
pub fn write_file_content(path: String, content: String) -> Result<(), String> {
// Ensure parent directory exists
if let Some(parent) = Path::new(&path).parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
fs::write(&path, content)
.map_err(|e| format!("Failed to write file {}: {}", path, e))
}
/// Write binary content to file (auto-creates parent directories)
#[tauri::command]
pub async fn write_binary_file(file_path: String, content: Vec<u8>) -> Result<(), String> {
// Ensure parent directory exists
if let Some(parent) = Path::new(&file_path).parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
fs::write(&file_path, content)
.map_err(|e| format!("Failed to write binary file {}: {}", file_path, e))
}
/// Check if path exists
#[tauri::command]
pub fn path_exists(path: String) -> Result<bool, String> {
Ok(Path::new(&path).exists())
}
/// Create directory (recursive)
#[tauri::command]
pub fn create_directory(path: String) -> Result<(), String> {
fs::create_dir_all(&path)
.map_err(|e| format!("Failed to create directory {}: {}", path, e))
}
/// Create empty file
#[tauri::command]
pub fn create_file(path: String) -> Result<(), String> {
fs::File::create(&path)
.map_err(|e| format!("Failed to create file {}: {}", path, e))?;
Ok(())
}
/// Delete file
#[tauri::command]
pub fn delete_file(path: String) -> Result<(), String> {
fs::remove_file(&path)
.map_err(|e| format!("Failed to delete file {}: {}", path, e))
}
/// Delete directory (recursive)
/// 递归删除目录
#[tauri::command]
pub fn delete_folder(path: String) -> Result<(), String> {
println!("[delete_folder] Attempting to delete: {}", path);
// Check if path exists
// 检查路径是否存在
let dir_path = std::path::Path::new(&path);
if !dir_path.exists() {
println!("[delete_folder] Path does not exist: {}", path);
return Err(format!("Directory does not exist: {}", path));
}
if !dir_path.is_dir() {
println!("[delete_folder] Path is not a directory: {}", path);
return Err(format!("Path is not a directory: {}", path));
}
match fs::remove_dir_all(&path) {
Ok(_) => {
println!("[delete_folder] Successfully deleted: {}", path);
Ok(())
}
Err(e) => {
let error_msg = format!("Failed to delete folder {}: {}", path, e);
eprintln!("[delete_folder] Error: {}", error_msg);
Err(error_msg)
}
}
}
/// Rename or move file/folder
#[tauri::command]
pub fn rename_file_or_folder(old_path: String, new_path: String) -> Result<(), String> {
fs::rename(&old_path, &new_path)
.map_err(|e| format!("Failed to rename {} to {}: {}", old_path, new_path, e))
}
/// List directory contents with metadata
#[tauri::command]
pub fn list_directory(path: String) -> Result<Vec<DirectoryEntry>, String> {
let dir_path = Path::new(&path);
if !dir_path.exists() {
return Err(format!("Directory does not exist: {}", path));
}
if !dir_path.is_dir() {
return Err(format!("Path is not a directory: {}", path));
}
let mut entries = Vec::new();
let read_dir = fs::read_dir(dir_path)
.map_err(|e| format!("Failed to read directory {}: {}", path, e))?;
for entry in read_dir.flatten() {
let entry_path = entry.path();
if let Some(name) = entry_path.file_name() {
let is_dir = entry_path.is_dir();
let (size, modified) = fs::metadata(&entry_path)
.map(|metadata| {
let size = if is_dir { None } else { Some(metadata.len()) };
let modified = metadata
.modified()
.ok()
.and_then(|time| {
time.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
});
(size, modified)
})
.unwrap_or((None, None));
entries.push(DirectoryEntry {
name: name.to_string_lossy().to_string(),
path: entry_path.to_string_lossy().to_string(),
is_dir,
size,
modified,
});
}
}
// Sort: directories first, then alphabetically
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
Ok(entries)
}
/// Scan directory for files matching a glob pattern
#[tauri::command]
pub fn scan_directory(path: String, pattern: String) -> Result<Vec<String>, String> {
use glob::glob;
let base_path = Path::new(&path);
if !base_path.exists() {
return Err(format!("Directory does not exist: {}", path));
}
let separator = if path.contains('\\') { '\\' } else { '/' };
let glob_pattern = format!(
"{}{}{}",
path.trim_end_matches(&['/', '\\'][..]),
separator,
pattern
);
let normalized_pattern = if cfg!(windows) {
glob_pattern.replace('/', "\\")
} else {
glob_pattern.replace('\\', "/")
};
let mut files = Vec::new();
match glob(&normalized_pattern) {
Ok(entries) => {
for entry in entries.flatten() {
if entry.is_file() {
files.push(entry.to_string_lossy().to_string());
}
}
}
Err(e) => return Err(format!("Failed to scan directory: {}", e)),
}
Ok(files)
}
/// Read file as base64 encoded string
#[tauri::command]
pub fn read_file_as_base64(file_path: String) -> Result<String, String> {
use base64::{engine::general_purpose, Engine as _};
let file_content = fs::read(&file_path)
.map_err(|e| format!("Failed to read file {}: {}", file_path, e))?;
Ok(general_purpose::STANDARD.encode(&file_content))
}
/// Get file modification time (milliseconds since UNIX epoch)
/// 获取文件修改时间Unix 纪元以来的毫秒数)
#[tauri::command]
pub fn get_file_mtime(path: String) -> Result<u64, String> {
let metadata = fs::metadata(&path)
.map_err(|e| format!("Failed to get metadata for {}: {}", path, e))?;
let modified = metadata
.modified()
.map_err(|e| format!("Failed to get modified time for {}: {}", path, e))?;
let millis = modified
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("Time error: {}", e))?
.as_millis() as u64;
Ok(millis)
}
/// Copy file from source to destination
#[tauri::command]
pub fn copy_file(src: String, dst: String) -> Result<(), String> {
// Ensure parent directory exists
if let Some(parent) = Path::new(&dst).parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
fs::copy(&src, &dst)
.map_err(|e| format!("Failed to copy file {} to {}: {}", src, dst, e))?;
Ok(())
}

View File

@@ -0,0 +1,26 @@
//! Command modules.
//! 命令模块。
//!
//! All Tauri commands organized by domain.
//! 所有按领域组织的 Tauri 命令。
pub mod build;
pub mod compiler;
pub mod dialog;
pub mod file_system;
pub mod modules;
pub mod plugin;
pub mod profiler;
pub mod project;
pub mod system;
// Re-export all commands for convenience | 重新导出所有命令以方便使用
pub use build::*;
pub use compiler::*;
pub use dialog::*;
pub use file_system::*;
pub use modules::*;
pub use plugin::*;
pub use profiler::*;
pub use project::*;
pub use system::*;

View File

@@ -0,0 +1,193 @@
//! Engine Module Commands
//! 引擎模块命令
//!
//! Commands for reading engine module configurations.
//! 用于读取引擎模块配置的命令。
use std::path::PathBuf;
use tauri::{command, AppHandle};
#[cfg(not(debug_assertions))]
use tauri::Manager;
/// Module index structure.
/// 模块索引结构。
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ModuleIndex {
pub version: String,
#[serde(rename = "generatedAt")]
pub generated_at: String,
pub modules: Vec<ModuleIndexEntry>,
}
/// Module index entry.
/// 模块索引条目。
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ModuleIndexEntry {
pub id: String,
pub name: String,
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "hasRuntime")]
pub has_runtime: bool,
#[serde(rename = "editorPackage")]
pub editor_package: Option<String>,
#[serde(rename = "isCore")]
pub is_core: bool,
pub category: String,
/// JS bundle size in bytes | JS 包大小(字节)
#[serde(rename = "jsSize")]
pub js_size: Option<u64>,
/// Whether this module requires WASM | 是否需要 WASM
#[serde(rename = "requiresWasm")]
pub requires_wasm: Option<bool>,
/// WASM file size in bytes | WASM 文件大小(字节)
#[serde(rename = "wasmSize")]
pub wasm_size: Option<u64>,
}
/// Get the engine modules directory path.
/// 获取引擎模块目录路径。
///
/// In dev mode: First tries dist/engine, then falls back to packages/ source directory.
/// 在开发模式下:首先尝试 dist/engine然后回退到 packages/ 源目录。
///
/// In production: Uses the bundled resource directory.
/// 在生产模式下:使用打包的资源目录。
#[allow(unused_variables)]
fn get_engine_modules_path(app: &AppHandle) -> Result<PathBuf, String> {
// In development mode, use compile-time path
// 在开发模式下,使用编译时路径
#[cfg(debug_assertions)]
{
// CARGO_MANIFEST_DIR is set at compile time, pointing to src-tauri
// CARGO_MANIFEST_DIR 在编译时设置,指向 src-tauri
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Try dist/engine first (if modules have been copied with actual content)
// 首先尝试 dist/engine如果模块已复制且包含实际内容
let dist_engine_path = manifest_dir
.parent()
.map(|p| p.join("dist/engine"))
.unwrap_or_else(|| PathBuf::from("dist/engine"));
// Check if dist/engine has actual module content (not just empty directories)
// 检查 dist/engine 是否有实际模块内容(而不仅是空目录)
let dist_core_output = dist_engine_path.join("core/dist/index.mjs");
if dist_core_output.exists() {
println!("[modules] Using dist/engine path: {:?}", dist_engine_path);
return Ok(dist_engine_path);
}
// Fallback: use packages/ source directory directly (dev mode without copy)
// 回退:直接使用 packages/ 源目录(开发模式无需复制)
// This allows building without running copy-modules first
// 这样可以在不运行 copy-modules 的情况下进行构建
let packages_path = manifest_dir
.parent() // editor-app
.and_then(|p| p.parent()) // packages
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("packages"));
// Verify packages directory has module.json files
// 验证 packages 目录包含 module.json 文件
let core_module = packages_path.join("core/module.json");
if core_module.exists() {
println!("[modules] Using packages source path: {:?}", packages_path);
return Ok(packages_path);
}
return Err(format!(
"Engine modules directory not found in dev mode. Tried: {:?}, {:?}. \
Either run 'pnpm copy-modules' or ensure packages/ directory exists.",
dist_engine_path, packages_path
));
}
// Production: use resource directory
// 生产环境:使用资源目录
#[cfg(not(debug_assertions))]
{
let resource_path = app
.path()
.resource_dir()
.map_err(|e| format!("Failed to get resource dir: {}", e))?;
let prod_path = resource_path.join("engine");
if prod_path.exists() {
return Ok(prod_path);
}
// Fallback: try exe directory
// 回退:尝试可执行文件目录
let exe_path = std::env::current_exe()
.map_err(|e| format!("Failed to get exe path: {}", e))?;
let exe_dir = exe_path.parent()
.ok_or("Failed to get exe directory")?;
let exe_engine_path = exe_dir.join("engine");
if exe_engine_path.exists() {
return Ok(exe_engine_path);
}
Err(format!(
"Engine modules directory not found. Tried: {:?}, {:?}",
prod_path, exe_engine_path
))
}
}
/// Read the engine modules index.
/// 读取引擎模块索引。
#[command]
pub async fn read_engine_modules_index(app: AppHandle) -> Result<ModuleIndex, String> {
println!("[modules] read_engine_modules_index called");
let engine_path = get_engine_modules_path(&app)?;
println!("[modules] engine_path: {:?}", engine_path);
let index_path = engine_path.join("index.json");
if !index_path.exists() {
return Err(format!(
"Module index not found at {:?}. Run 'pnpm copy-modules' first.",
index_path
));
}
let content = std::fs::read_to_string(&index_path)
.map_err(|e| format!("Failed to read index.json: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse index.json: {}", e))
}
/// Read a specific module's manifest.
/// 读取特定模块的清单。
#[command]
pub async fn read_module_manifest(app: AppHandle, module_id: String) -> Result<serde_json::Value, String> {
let engine_path = get_engine_modules_path(&app)?;
let manifest_path = engine_path.join(&module_id).join("module.json");
if !manifest_path.exists() {
return Err(format!(
"Module manifest not found for '{}' at {:?}",
module_id, manifest_path
));
}
let content = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("Failed to read module.json for {}: {}", module_id, e))?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse module.json for {}: {}", module_id, e))
}
/// Get the base path to engine modules directory.
/// 获取引擎模块目录的基础路径。
#[command]
pub async fn get_engine_modules_base_path(app: AppHandle) -> Result<String, String> {
let path = get_engine_modules_path(&app)?;
path.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "Failed to convert path to string".to_string())
}

View File

@@ -0,0 +1,270 @@
//! Plugin management commands
//!
//! Building, installing, and uninstalling editor plugins.
use std::fs;
use std::io::{Cursor, Write};
use std::path::Path;
use std::process::Command;
use tauri::{AppHandle, Emitter};
use zip::write::FileOptions;
use zip::ZipArchive;
/// Build progress event payload
#[derive(serde::Serialize, Clone)]
pub struct BuildProgress {
pub step: String,
pub output: Option<String>,
}
/// Build a plugin from source
#[tauri::command]
pub async fn build_plugin(plugin_folder: String, app: AppHandle) -> Result<String, String> {
let plugin_path = Path::new(&plugin_folder);
if !plugin_path.exists() {
return Err(format!("Plugin folder does not exist: {}", plugin_folder));
}
let package_json_path = plugin_path.join("package.json");
if !package_json_path.exists() {
return Err("package.json not found in plugin folder".to_string());
}
let build_cache_dir = plugin_path.join(".build-cache");
if !build_cache_dir.exists() {
fs::create_dir_all(&build_cache_dir)
.map_err(|e| format!("Failed to create .build-cache directory: {}", e))?;
}
let pnpm_command = if cfg!(target_os = "windows") {
"pnpm.cmd"
} else {
"pnpm"
};
// Step 1: Install dependencies
app.emit(
"plugin-build-progress",
BuildProgress {
step: "install".to_string(),
output: None,
},
)
.ok();
let install_output = Command::new(&pnpm_command)
.args(["install"])
.current_dir(&plugin_folder)
.output()
.map_err(|e| format!("Failed to run pnpm install: {}", e))?;
if !install_output.status.success() {
return Err(format!(
"pnpm install failed: {}",
String::from_utf8_lossy(&install_output.stderr)
));
}
// Step 2: Build
app.emit(
"plugin-build-progress",
BuildProgress {
step: "build".to_string(),
output: None,
},
)
.ok();
let build_output = Command::new(&pnpm_command)
.args(["run", "build"])
.current_dir(&plugin_folder)
.output()
.map_err(|e| format!("Failed to run pnpm run build: {}", e))?;
if !build_output.status.success() {
return Err(format!(
"pnpm run build failed: {}",
String::from_utf8_lossy(&build_output.stderr)
));
}
let dist_path = plugin_path.join("dist");
if !dist_path.exists() {
return Err("dist directory not found after build".to_string());
}
// Step 3: Package
app.emit(
"plugin-build-progress",
BuildProgress {
step: "package".to_string(),
output: None,
},
)
.ok();
let zip_path = build_cache_dir.join("index.zip");
let zip_file =
fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?;
let mut zip = zip::ZipWriter::new(zip_file);
let options = FileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
// Add package.json
let package_json_content = fs::read(&package_json_path)
.map_err(|e| format!("Failed to read package.json: {}", e))?;
zip.start_file("package.json", options)
.map_err(|e| format!("Failed to add package.json to zip: {}", e))?;
zip.write_all(&package_json_content)
.map_err(|e| format!("Failed to write package.json to zip: {}", e))?;
// Add dist directory
add_directory_to_zip(&mut zip, plugin_path, &dist_path, options)
.map_err(|e| format!("Failed to add dist directory to zip: {}", e))?;
zip.finish()
.map_err(|e| format!("Failed to finalize zip: {}", e))?;
// Step 4: Complete
app.emit(
"plugin-build-progress",
BuildProgress {
step: "complete".to_string(),
output: None,
},
)
.ok();
Ok(zip_path.to_string_lossy().to_string())
}
fn add_directory_to_zip<W: std::io::Write + std::io::Seek>(
zip: &mut zip::ZipWriter<W>,
base_path: &Path,
current_path: &Path,
options: FileOptions,
) -> Result<(), String> {
let entries = fs::read_dir(current_path)
.map_err(|e| format!("Failed to read directory {}: {}", current_path.display(), e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
add_directory_to_zip(zip, base_path, &path, options)?;
} else {
let relative_path = path
.strip_prefix(base_path)
.map_err(|e| format!("Failed to get relative path: {}", e))?;
let zip_path = relative_path.to_string_lossy().replace('\\', "/");
let file_content = fs::read(&path)
.map_err(|e| format!("Failed to read file {}: {}", path.display(), e))?;
zip.start_file(&zip_path, options)
.map_err(|e| format!("Failed to add file {} to zip: {}", zip_path, e))?;
zip.write_all(&file_content)
.map_err(|e| format!("Failed to write file {} to zip: {}", zip_path, e))?;
}
}
Ok(())
}
/// Install a plugin from marketplace
#[tauri::command]
pub async fn install_marketplace_plugin(
project_path: String,
plugin_id: String,
zip_data_base64: String,
) -> Result<String, String> {
use base64::{engine::general_purpose, Engine as _};
let project_path = Path::new(&project_path);
if !project_path.exists() {
return Err(format!(
"Project path does not exist: {}",
project_path.display()
));
}
let plugins_dir = project_path.join("plugins");
if !plugins_dir.exists() {
fs::create_dir_all(&plugins_dir)
.map_err(|e| format!("Failed to create plugins directory: {}", e))?;
}
let plugin_dir = plugins_dir.join(&plugin_id);
if plugin_dir.exists() {
fs::remove_dir_all(&plugin_dir)
.map_err(|e| format!("Failed to remove old plugin directory: {}", e))?;
}
fs::create_dir_all(&plugin_dir)
.map_err(|e| format!("Failed to create plugin directory: {}", e))?;
let zip_bytes = general_purpose::STANDARD
.decode(&zip_data_base64)
.map_err(|e| format!("Failed to decode base64 ZIP data: {}", e))?;
let cursor = Cursor::new(zip_bytes);
let mut archive =
ZipArchive::new(cursor).map_err(|e| format!("Failed to read ZIP archive: {}", e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Failed to read ZIP entry {}: {}", i, e))?;
let file_path = match file.enclosed_name() {
Some(path) => path,
None => continue,
};
let out_path = plugin_dir.join(file_path);
if file.is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Failed to create directory {}: {}", out_path.display(), e))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create parent directory: {}", e))?;
}
let mut out_file = fs::File::create(&out_path)
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
std::io::copy(&mut file, &mut out_file)
.map_err(|e| format!("Failed to write file {}: {}", out_path.display(), e))?;
}
}
Ok(plugin_dir.to_string_lossy().to_string())
}
/// Uninstall a plugin
#[tauri::command]
pub async fn uninstall_marketplace_plugin(
project_path: String,
plugin_id: String,
) -> Result<(), String> {
let project_path = Path::new(&project_path);
let plugin_dir = project_path.join("plugins").join(&plugin_id);
if !plugin_dir.exists() {
return Err(format!(
"Plugin directory does not exist: {}",
plugin_dir.display()
));
}
fs::remove_dir_all(&plugin_dir)
.map_err(|e| format!("Failed to remove plugin directory: {}", e))?;
Ok(())
}

View File

@@ -0,0 +1,58 @@
//! Profiler server commands
//!
//! WebSocket profiler server management.
use std::sync::Arc;
use crate::profiler_ws::ProfilerServer;
use crate::state::ProfilerState;
/// Start the profiler WebSocket server
#[tauri::command]
pub async fn start_profiler_server(
port: u16,
state: tauri::State<'_, ProfilerState>,
) -> Result<String, String> {
let mut server_lock = state.server.lock().await;
if server_lock.is_some() {
return Err("Profiler server is already running".to_string());
}
let server = Arc::new(ProfilerServer::new(port));
match server.start().await {
Ok(_) => {
*server_lock = Some(server);
Ok(format!("Profiler server started on port {}", port))
}
Err(e) => Err(format!("Failed to start profiler server: {}", e)),
}
}
/// Stop the profiler WebSocket server
#[tauri::command]
pub async fn stop_profiler_server(
state: tauri::State<'_, ProfilerState>,
) -> Result<String, String> {
let mut server_lock = state.server.lock().await;
if server_lock.is_none() {
return Err("Profiler server is not running".to_string());
}
if let Some(server) = server_lock.as_ref() {
server.stop().await;
}
*server_lock = None;
Ok("Profiler server stopped".to_string())
}
/// Get profiler server status
#[tauri::command]
pub async fn get_profiler_status(
state: tauri::State<'_, ProfilerState>,
) -> Result<bool, String> {
let server_lock = state.server.lock().await;
Ok(server_lock.is_some())
}

View File

@@ -0,0 +1,77 @@
//! Project management commands
use std::fs;
use std::path::Path;
use crate::state::ProjectPaths;
#[tauri::command]
pub fn open_project(path: String) -> Result<String, String> {
Ok(format!("Project opened: {}", path))
}
/// Save project data
#[tauri::command]
pub fn save_project(path: String, data: String) -> Result<(), String> {
fs::write(&path, data).map_err(|e| format!("Failed to save project: {}", e))
}
/// Export binary data
#[tauri::command]
pub fn export_binary(data: Vec<u8>, output_path: String) -> Result<(), String> {
fs::write(&output_path, data).map_err(|e| format!("Failed to export binary: {}", e))
}
/// Set current project base path
#[tauri::command]
pub fn set_project_base_path(path: String, state: tauri::State<ProjectPaths>) -> Result<(), String> {
let mut paths = state
.lock()
.map_err(|e| format!("Failed to lock state: {}", e))?;
paths.insert("current".to_string(), path);
Ok(())
}
/// Scan for behavior tree files in project
#[tauri::command]
pub fn scan_behavior_trees(project_path: String) -> Result<Vec<String>, String> {
let behaviors_path = Path::new(&project_path).join(".ecs").join("behaviors");
if !behaviors_path.exists() {
fs::create_dir_all(&behaviors_path)
.map_err(|e| format!("Failed to create behaviors directory: {}", e))?;
return Ok(Vec::new());
}
let mut btree_files = Vec::new();
scan_directory_recursive(&behaviors_path, &behaviors_path, &mut btree_files)?;
Ok(btree_files)
}
fn scan_directory_recursive(
base_path: &Path,
current_path: &Path,
results: &mut Vec<String>,
) -> Result<(), String> {
let entries =
fs::read_dir(current_path).map_err(|e| format!("Failed to read directory: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_directory_recursive(base_path, &path, results)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("btree") {
if let Ok(relative) = path.strip_prefix(base_path) {
let relative_str = relative
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches(".btree")
.to_string();
results.push(relative_str);
}
}
}
Ok(())
}

View File

@@ -0,0 +1,749 @@
//! System operations
//!
//! OS-level operations like opening files, showing in folder, devtools, etc.
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::net::UdpSocket;
use tauri::{AppHandle, Manager};
use tiny_http::{Server, Response};
use qrcode::QrCode;
use image::Luma;
// Global server state
static SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
static SERVER_STOP_FLAG: once_cell::sync::Lazy<Arc<AtomicBool>> =
once_cell::sync::Lazy::new(|| Arc::new(AtomicBool::new(false)));
/// Toggle developer tools (debug mode only)
#[tauri::command]
pub fn toggle_devtools(app: AppHandle) -> Result<(), String> {
#[cfg(debug_assertions)]
{
if let Some(window) = app.get_webview_window("main") {
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
Ok(())
} else {
Err("Window not found".to_string())
}
}
#[cfg(not(debug_assertions))]
{
let _ = app;
Err("DevTools are only available in debug mode".to_string())
}
}
/// Open file with system default application
#[tauri::command]
pub fn open_file_with_default_app(file_path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", "", &file_path])
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(&file_path)
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open")
.arg(&file_path)
.spawn()
.map_err(|e| format!("Failed to open file: {}", e))?;
}
Ok(())
}
/// Open folder in system file explorer
/// 在系统文件管理器中打开文件夹
#[tauri::command]
pub fn open_folder(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let normalized_path = path.replace('/', "\\");
Command::new("explorer")
.arg(&normalized_path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
Ok(())
}
/// Show file in system file explorer
#[tauri::command]
pub fn show_in_folder(file_path: String) -> Result<(), String> {
println!("[show_in_folder] Received path: {}", file_path);
#[cfg(target_os = "windows")]
{
use std::path::Path;
// Normalize path separators for Windows
// 规范化路径分隔符
let normalized_path = file_path.replace('/', "\\");
println!("[show_in_folder] Normalized path: {}", normalized_path);
// Verify the path exists before trying to show it
// 验证路径存在
let path = Path::new(&normalized_path);
let exists = path.exists();
println!("[show_in_folder] Path exists: {}", exists);
if !exists {
return Err(format!("Path does not exist: {}", normalized_path));
}
// Windows explorer requires /select, to be concatenated with the path
// without spaces. Use a single argument to avoid shell parsing issues.
// Windows 资源管理器要求 /select, 与路径连接在一起,中间没有空格
let select_arg = format!("/select,{}", normalized_path);
println!("[show_in_folder] Explorer arg: {}", select_arg);
Command::new("explorer")
.arg(&select_arg)
.spawn()
.map_err(|e| format!("Failed to show in folder: {}", e))?;
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.args(["-R", &file_path])
.spawn()
.map_err(|e| format!("Failed to show in folder: {}", e))?;
}
#[cfg(target_os = "linux")]
{
use std::path::Path;
let path = Path::new(&file_path);
let parent = path
.parent()
.ok_or_else(|| "Failed to get parent directory".to_string())?;
Command::new("xdg-open")
.arg(parent)
.spawn()
.map_err(|e| format!("Failed to show in folder: {}", e))?;
}
Ok(())
}
/// Get system temp directory
#[tauri::command]
pub fn get_temp_dir() -> Result<String, String> {
std::env::temp_dir()
.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "Failed to get temp directory".to_string())
}
/// 使用 where 命令查找可执行文件路径
/// Use 'where' command to find executable path
#[cfg(target_os = "windows")]
fn find_command_path(cmd: &str) -> Option<String> {
use std::process::Command as StdCommand;
use std::path::Path;
// 使用 where 命令查找
let output = StdCommand::new("where")
.arg(cmd)
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
// 取第一行结果(可能有多个匹配)
if let Some(first_line) = stdout.lines().next() {
let path = first_line.trim();
if !path.is_empty() {
let path_obj = Path::new(path);
// 检查是否是 bin 目录下的脚本VSCode/Cursor 特征)
// Check if it's a script in bin directory (VSCode/Cursor pattern)
let is_bin_script = path_obj.parent()
.map(|p| p.ends_with("bin"))
.unwrap_or(false);
// 如果找到的是 .cmd 或 .bat或者是 bin 目录下的脚本where 可能不返回扩展名)
// If found .cmd or .bat, or a script in bin directory (where may not return extension)
let has_script_ext = path.ends_with(".cmd") || path.ends_with(".bat");
if has_script_ext || is_bin_script {
// 尝试找 Code.exe (VSCode) 或 Cursor.exe 等
// Try to find Code.exe (VSCode) or Cursor.exe etc.
if let Some(bin_dir) = path_obj.parent() {
if let Some(parent_dir) = bin_dir.parent() {
// VSCode: bin/code.cmd -> Code.exe
let exe_path = parent_dir.join("Code.exe");
if exe_path.exists() {
let exe_str = exe_path.to_string_lossy().to_string();
println!("[find_command_path] Found {} exe at: {}", cmd, exe_str);
return Some(exe_str);
}
// Cursor: bin/cursor.cmd -> Cursor.exe
let cursor_exe = parent_dir.join("Cursor.exe");
if cursor_exe.exists() {
let exe_str = cursor_exe.to_string_lossy().to_string();
println!("[find_command_path] Found {} exe at: {}", cmd, exe_str);
return Some(exe_str);
}
}
}
}
println!("[find_command_path] Found {} at: {}", cmd, path);
return Some(path.to_string());
}
}
}
None
}
#[cfg(not(target_os = "windows"))]
fn find_command_path(cmd: &str) -> Option<String> {
use std::process::Command as StdCommand;
let output = StdCommand::new("which")
.arg(cmd)
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let path = stdout.trim();
if !path.is_empty() {
return Some(path.to_string());
}
}
None
}
/// 解析编辑器命令,返回实际可执行路径
/// Resolve editor command to actual executable path
fn resolve_editor_command(editor_command: &str) -> String {
use std::path::Path;
// 如果命令已经是完整路径且存在,直接返回
// If command is already a full path and exists, return it
if Path::new(editor_command).exists() {
return editor_command.to_string();
}
// 使用系统命令查找可执行文件路径
// Use system command to find executable path
if let Some(path) = find_command_path(editor_command) {
return path;
}
// 回退到原始命令 | Fall back to original command
editor_command.to_string()
}
/// Open project folder with specified editor
/// 使用指定编辑器打开项目文件夹
///
/// @param project_path - Project folder path | 项目文件夹路径
/// @param editor_command - Editor command (e.g., "code", "cursor") | 编辑器命令
/// @param file_path - Optional file to open (will be opened in the editor) | 可选的要打开的文件
#[tauri::command]
pub fn open_with_editor(
project_path: String,
editor_command: String,
file_path: Option<String>,
) -> Result<(), String> {
use std::path::Path;
// Normalize paths
let normalized_project = project_path.replace('/', "\\");
let normalized_file = file_path.map(|f| f.replace('/', "\\"));
// Verify project path exists
let project = Path::new(&normalized_project);
if !project.exists() {
return Err(format!("Project path does not exist: {}", normalized_project));
}
// 解析编辑器命令到实际路径
// Resolve editor command to actual path
let resolved_command = resolve_editor_command(&editor_command);
println!(
"[open_with_editor] editor: {} -> {}, project: {}, file: {:?}",
editor_command, resolved_command, normalized_project, normalized_file
);
let mut cmd = Command::new(&resolved_command);
// VSCode/Cursor CLI 正确用法:
// 1. 使用 --folder-uri 或直接传文件夹路径会打开新窗口
// 2. 使用 --add 可以将文件夹添加到当前工作区
// 3. 使用 --goto file:line:column 可以打开文件并定位
//
// VSCode/Cursor CLI correct usage:
// 1. Use --folder-uri or pass folder path directly to open new window
// 2. Use --add to add folder to current workspace
// 3. Use --goto file:line:column to open file and navigate
//
// 正确命令格式: code <folder> <file>
// 这会打开文件夹并同时打开文件
// Correct command format: code <folder> <file>
// This opens the folder and also opens the file
// Add project folder first
// 先添加项目文件夹
cmd.arg(&normalized_project);
// If a specific file is provided, add it directly (not with -g)
// VSCode will open the folder AND the file
// 如果提供了文件,直接添加(不使用 -g
// VSCode 会同时打开文件夹和文件
if let Some(ref file) = normalized_file {
let file_path_obj = Path::new(file);
if file_path_obj.exists() {
cmd.arg(file);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open with editor '{}': {}", resolved_command, e))?;
Ok(())
}
/// Get application resource directory
#[tauri::command]
pub fn get_app_resource_dir(app: AppHandle) -> Result<String, String> {
app.path()
.resource_dir()
.map_err(|e| format!("Failed to get resource directory: {}", e))
.and_then(|p| {
p.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "Invalid path encoding".to_string())
})
}
/// Get current working directory
#[tauri::command]
pub fn get_current_dir() -> Result<String, String> {
std::env::current_dir()
.and_then(|p| Ok(p.to_string_lossy().to_string()))
.map_err(|e| format!("Failed to get current directory: {}", e))
}
/// Update project tsconfig.json with engine type paths
/// 更新项目的 tsconfig.json添加引擎类型路径
///
/// Scans dist/engine/ directory and adds paths for all modules with .d.ts files.
/// 扫描 dist/engine/ 目录,为所有有 .d.ts 文件的模块添加路径。
#[tauri::command]
pub fn update_project_tsconfig(app: AppHandle, project_path: String) -> Result<(), String> {
use std::path::Path;
let project = Path::new(&project_path);
if !project.exists() {
return Err(format!("Project path does not exist: {}", project_path));
}
// Get engine modules path (dist/engine/)
// 获取引擎模块路径
let engine_path = get_engine_modules_base_path_internal(&app)?;
// Read existing tsconfig.json
// 读取现有的 tsconfig.json
let tsconfig_path = project.join("tsconfig.json");
let tsconfig_editor_path = project.join("tsconfig.editor.json");
// Update runtime tsconfig
// 更新运行时 tsconfig
if tsconfig_path.exists() {
update_tsconfig_file(&tsconfig_path, &engine_path, false)?;
println!("[update_project_tsconfig] Updated {}", tsconfig_path.display());
}
// Update editor tsconfig
// 更新编辑器 tsconfig
if tsconfig_editor_path.exists() {
update_tsconfig_file(&tsconfig_editor_path, &engine_path, true)?;
println!("[update_project_tsconfig] Updated {}", tsconfig_editor_path.display());
}
Ok(())
}
/// Internal function to get engine modules base path
/// 内部函数:获取引擎模块基础路径
fn get_engine_modules_base_path_internal(app: &AppHandle) -> Result<String, String> {
let resource_dir = app.path()
.resource_dir()
.map_err(|e| format!("Failed to get resource directory: {}", e))?;
// Production mode: resource_dir/engine/
// 生产模式
let prod_path = resource_dir.join("engine");
if prod_path.exists() {
return prod_path.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "Invalid path encoding".to_string());
}
// Development mode: workspace/packages/editor-app/dist/engine/
// 开发模式
if let Some(ws_root) = find_workspace_root() {
let dev_path = ws_root.join("packages").join("editor-app").join("dist").join("engine");
if dev_path.exists() {
return dev_path.to_str()
.map(|s| s.to_string())
.ok_or_else(|| "Invalid path encoding".to_string());
}
}
Err("Engine modules directory not found".to_string())
}
/// Find workspace root directory
/// 查找工作区根目录
fn find_workspace_root() -> Option<std::path::PathBuf> {
std::env::current_dir()
.ok()
.and_then(|cwd| {
let mut dir = cwd.as_path();
loop {
if dir.join("pnpm-workspace.yaml").exists() {
return Some(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => return None,
}
}
})
}
/// Update a tsconfig file with engine paths
/// 使用引擎路径更新 tsconfig 文件
///
/// Scans all subdirectories in engine_path for index.d.ts files.
/// 扫描 engine_path 下所有子目录的 index.d.ts 文件。
fn update_tsconfig_file(
tsconfig_path: &std::path::Path,
engine_path: &str,
include_editor: bool,
) -> Result<(), String> {
use std::fs;
let content = fs::read_to_string(tsconfig_path)
.map_err(|e| format!("Failed to read tsconfig: {}", e))?;
let mut config: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse tsconfig: {}", e))?;
// Normalize path for cross-platform compatibility
// 规范化路径以实现跨平台兼容
let engine_path_normalized = engine_path.replace('\\', "/");
// Build paths mapping by scanning engine modules directory
// 通过扫描引擎模块目录构建路径映射
let mut paths = serde_json::Map::new();
let mut module_count = 0;
let engine_dir = std::path::Path::new(engine_path);
if let Ok(entries) = fs::read_dir(engine_dir) {
for entry in entries.flatten() {
let module_path = entry.path();
if !module_path.is_dir() {
continue;
}
let module_id = module_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Skip editor modules for runtime tsconfig
// 运行时 tsconfig 跳过编辑器模块
if !include_editor && module_id.ends_with("-editor") {
continue;
}
// Check for index.d.ts
// 检查是否存在 index.d.ts
let dts_path = module_path.join("index.d.ts");
if !dts_path.exists() {
continue;
}
// Read module.json to get the actual package name
// 读取 module.json 获取实际的包名
let module_json_path = module_path.join("module.json");
let module_name = if module_json_path.exists() {
fs::read_to_string(&module_json_path)
.ok()
.and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
.and_then(|json| json.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| format!("@esengine/{}", module_id))
} else {
format!("@esengine/{}", module_id)
};
let dts_path_str = format!("{}/{}/index.d.ts", engine_path_normalized, module_id);
paths.insert(module_name, serde_json::json!([dts_path_str]));
module_count += 1;
}
}
println!("[update_tsconfig_file] Found {} modules with type definitions", module_count);
// Update compilerOptions.paths
// 更新 compilerOptions.paths
if let Some(compiler_options) = config.get_mut("compilerOptions") {
if let Some(obj) = compiler_options.as_object_mut() {
obj.insert("paths".to_string(), serde_json::Value::Object(paths));
// Remove typeRoots since we're using paths
// 移除 typeRoots因为我们使用 paths
obj.remove("typeRoots");
}
}
// Write back
// 写回文件
let output = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize tsconfig: {}", e))?;
fs::write(tsconfig_path, output)
.map_err(|e| format!("Failed to write tsconfig: {}", e))?;
Ok(())
}
/// Start a local HTTP server for runtime preview
#[tauri::command]
pub fn start_local_server(root_path: String, port: u16) -> Result<String, String> {
// If server already running, just return the URL (server persists)
if SERVER_RUNNING.load(Ordering::SeqCst) {
return Ok(format!("http://127.0.0.1:{}", port));
}
SERVER_STOP_FLAG.store(false, Ordering::SeqCst);
SERVER_RUNNING.store(true, Ordering::SeqCst);
// Bind to 0.0.0.0 to allow LAN access
let addr = format!("0.0.0.0:{}", port);
let server = Server::http(&addr)
.map_err(|e| format!("Failed to start server: {}", e))?;
let root = root_path.clone();
let stop_flag = Arc::clone(&SERVER_STOP_FLAG);
thread::spawn(move || {
loop {
if stop_flag.load(Ordering::SeqCst) {
break;
}
// Use recv_timeout to allow checking stop flag periodically
match server.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(Some(request)) => {
let url = request.url().to_string();
// Split URL and query string
let url_without_query = url.split('?').next().unwrap_or(&url);
// Handle different request types
let file_path = if url.starts_with("/asset?path=") {
// Asset proxy - extract and decode path parameter
// 资产代理 - 提取并解码路径参数
let query = &url[7..]; // Skip "/asset?"
if let Some(path_value) = query.strip_prefix("path=") {
let decoded = urlencoding::decode(path_value)
.map(|s| s.to_string())
.unwrap_or_default();
// Normalize path: remove ./ prefix and join with root
// 规范化路径:移除 ./ 前缀并与根目录连接
let normalized = decoded.trim_start_matches("./");
PathBuf::from(&root).join(normalized)
.to_string_lossy()
.to_string()
} else {
String::new()
}
} else if url_without_query == "/" || url_without_query.is_empty() {
// Root - serve index.html
PathBuf::from(&root).join("index.html")
.to_string_lossy()
.to_string()
} else {
// Static files - remove leading slash and append to root
let path = url_without_query.trim_start_matches('/');
PathBuf::from(&root).join(path)
.to_string_lossy()
.to_string()
};
println!("[DevServer] Request: {} -> {}", url, file_path);
let response = match std::fs::read(&file_path) {
Ok(content) => {
let content_type = if file_path.ends_with(".html") {
"text/html; charset=utf-8"
} else if file_path.ends_with(".js") {
"application/javascript"
} else if file_path.ends_with(".wasm") {
"application/wasm"
} else if file_path.ends_with(".css") {
"text/css"
} else if file_path.ends_with(".json") {
"application/json"
} else if file_path.ends_with(".png") {
"image/png"
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
"image/jpeg"
} else {
"application/octet-stream"
};
Response::from_data(content)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
.unwrap(),
)
.with_header(
tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..])
.unwrap(),
)
}
Err(_) => Response::from_string("Not Found")
.with_status_code(404),
};
let _ = request.respond(response);
}
Ok(None) => {
// Timeout, continue loop
}
Err(_) => {
// Error, exit loop
break;
}
}
}
SERVER_RUNNING.store(false, Ordering::SeqCst);
});
Ok(format!("http://127.0.0.1:{}", port))
}
/// Stop the local HTTP server
#[tauri::command]
pub fn stop_local_server() -> Result<(), String> {
SERVER_STOP_FLAG.store(true, Ordering::SeqCst);
Ok(())
}
/// Get local IP address for LAN access
#[tauri::command]
pub fn get_local_ip() -> Result<String, String> {
// Use ipconfig on Windows to get the real LAN IP
#[cfg(target_os = "windows")]
{
let output = Command::new("cmd")
.args(["/C", "ipconfig"])
.output()
.map_err(|e| format!("Failed to run ipconfig: {}", e))?;
let output_str = String::from_utf8_lossy(&output.stdout);
// Parse ipconfig output to find IPv4 addresses
let mut found_ips: Vec<String> = Vec::new();
for line in output_str.lines() {
if line.contains("IPv4") || line.contains("IP Address") {
// Extract IP from line like " IPv4 Address. . . . . . . . . . . : 192.168.1.100"
if let Some(ip_part) = line.split(':').nth(1) {
let ip = ip_part.trim().to_string();
// Prefer 192.168.x.x or 10.x.x.x addresses
if ip.starts_with("192.168.") || ip.starts_with("10.") {
return Ok(ip);
}
// Collect other IPs as fallback, skip virtual ones
if !ip.starts_with("172.") && !ip.starts_with("127.") && !ip.starts_with("169.254.") {
found_ips.push(ip);
}
}
}
}
// Return first non-virtual IP found
if let Some(ip) = found_ips.first() {
return Ok(ip.clone());
}
}
// Fallback for non-Windows or if ipconfig fails
let socket = UdpSocket::bind("0.0.0.0:0")
.map_err(|e| format!("Failed to bind socket: {}", e))?;
socket.connect("8.8.8.8:80")
.map_err(|e| format!("Failed to connect: {}", e))?;
let local_addr = socket.local_addr()
.map_err(|e| format!("Failed to get local address: {}", e))?;
Ok(local_addr.ip().to_string())
}
/// Generate QR code as base64 PNG
#[tauri::command]
pub fn generate_qrcode(text: String) -> Result<String, String> {
let code = QrCode::new(text.as_bytes())
.map_err(|e| format!("Failed to create QR code: {}", e))?;
let image = code.render::<Luma<u8>>()
.min_dimensions(200, 200)
.build();
let mut png_data = Vec::new();
let mut cursor = std::io::Cursor::new(&mut png_data);
image.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode PNG: {}", e))?;
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png_data))
}

View File

@@ -0,0 +1,10 @@
//! ECS Editor Library
//!
//! Exports all public modules for the Tauri application.
pub mod commands;
pub mod profiler_ws;
pub mod state;
// Re-export commonly used types
pub use state::{ProfilerState, ProjectPaths};

View File

@@ -0,0 +1,212 @@
//! ESEngine Editor - Tauri Backend
//!
//! Clean entry point that handles application setup and command registration.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands;
mod profiler_ws;
mod state;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tauri::Manager;
use state::{ProfilerState, ProjectPaths, ScriptWatcherState};
fn main() {
// Initialize shared state | 初始化共享状态
let project_paths: ProjectPaths = Arc::new(Mutex::new(HashMap::new()));
let project_paths_for_protocol = Arc::clone(&project_paths);
let profiler_state = ProfilerState::new();
let script_watcher_state = ScriptWatcherState::new();
// Build and run the Tauri application
tauri::Builder::default()
// Register plugins
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_cli::init())
// Register custom URI scheme for project files
.register_uri_scheme_protocol("project", move |_app, request| {
handle_project_protocol(request, &project_paths_for_protocol)
})
// Setup application state | 设置应用状态
.setup(move |app| {
app.manage(project_paths);
app.manage(profiler_state);
app.manage(script_watcher_state);
Ok(())
})
// Register all commands
.invoke_handler(tauri::generate_handler![
// Project management
commands::open_project,
commands::save_project,
commands::export_binary,
commands::set_project_base_path,
commands::scan_behavior_trees,
// File system operations
commands::read_file_content,
commands::write_file_content,
commands::write_binary_file,
commands::append_to_log,
commands::path_exists,
commands::create_directory,
commands::create_file,
commands::delete_file,
commands::delete_folder,
commands::rename_file_or_folder,
commands::list_directory,
commands::scan_directory,
commands::read_file_as_base64,
commands::copy_file,
commands::get_file_mtime,
// Dialog operations
commands::open_folder_dialog,
commands::open_file_dialog,
commands::save_file_dialog,
// Profiler server
commands::start_profiler_server,
commands::stop_profiler_server,
commands::get_profiler_status,
// Plugin management
commands::build_plugin,
commands::install_marketplace_plugin,
commands::uninstall_marketplace_plugin,
// System operations
commands::toggle_devtools,
commands::open_file_with_default_app,
commands::open_folder,
commands::show_in_folder,
commands::get_temp_dir,
commands::open_with_editor,
commands::update_project_tsconfig,
commands::get_app_resource_dir,
commands::get_current_dir,
commands::start_local_server,
commands::stop_local_server,
commands::get_local_ip,
commands::generate_qrcode,
// User code compilation | 用户代码编译
commands::compile_typescript,
commands::check_types,
commands::watch_scripts,
commands::watch_assets,
commands::stop_watch_scripts,
commands::check_environment,
commands::install_esbuild,
// Build commands | 构建命令
commands::prepare_build_directory,
commands::copy_directory,
commands::bundle_scripts,
commands::generate_html,
commands::get_file_size,
commands::get_directory_size,
commands::write_json_file,
commands::list_files_by_extension,
commands::read_binary_file_as_base64,
commands::read_binary_file,
// Engine modules | 引擎模块
commands::read_engine_modules_index,
commands::read_module_manifest,
commands::get_engine_modules_base_path,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// Handle the custom 'project://' URI scheme protocol
///
/// This allows the frontend to load project files through a custom protocol,
/// enabling features like hot-reloading plugins from the project directory.
fn handle_project_protocol(
request: tauri::http::Request<Vec<u8>>,
project_paths: &ProjectPaths,
) -> tauri::http::Response<Vec<u8>> {
let uri = request.uri();
let path = uri.path();
// Debug logging
println!("[project://] Full URI: {}", uri);
println!("[project://] Path: {}", path);
let file_path = {
let paths = match project_paths.lock() {
Ok(p) => p,
Err(_) => {
return tauri::http::Response::builder()
.status(500)
.body(Vec::new())
.unwrap();
}
};
match paths.get("current") {
Some(base_path) => format!("{}{}", base_path, path),
None => {
return tauri::http::Response::builder()
.status(404)
.body(Vec::new())
.unwrap();
}
}
};
match std::fs::read(&file_path) {
Ok(content) => {
let mime_type = get_mime_type(&file_path);
tauri::http::Response::builder()
.status(200)
.header("Content-Type", mime_type)
// CORS headers for dynamic ES module imports | 动态 ES 模块导入所需的 CORS 头
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "GET, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.header("Access-Control-Expose-Headers", "Content-Length")
// Allow cross-origin script loading | 允许跨域脚本加载
.header("Cross-Origin-Resource-Policy", "cross-origin")
.body(content)
.unwrap()
}
Err(e) => {
eprintln!("Failed to read file {}: {}", file_path, e);
tauri::http::Response::builder()
.status(404)
.header("Access-Control-Allow-Origin", "*")
.body(Vec::new())
.unwrap()
}
}
}
/// Get MIME type based on file extension
/// 根据文件扩展名获取 MIME 类型
fn get_mime_type(file_path: &str) -> &'static str {
if file_path.ends_with(".ts") || file_path.ends_with(".tsx") {
"application/javascript"
} else if file_path.ends_with(".js") || file_path.ends_with(".mjs") {
"application/javascript"
} else if file_path.ends_with(".json") {
"application/json"
} else if file_path.ends_with(".wasm") {
"application/wasm"
} else if file_path.ends_with(".css") {
"text/css"
} else if file_path.ends_with(".html") {
"text/html"
} else if file_path.ends_with(".png") {
"image/png"
} else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") {
"image/jpeg"
} else if file_path.ends_with(".svg") {
"image/svg+xml"
} else {
"application/octet-stream"
}
}

View File

@@ -0,0 +1,193 @@
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, Mutex};
use tokio::task::JoinHandle;
use tokio_tungstenite::{accept_async, tungstenite::Message};
use futures_util::{SinkExt, StreamExt};
pub struct ProfilerServer {
tx: broadcast::Sender<String>,
port: u16,
shutdown_tx: Arc<Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
task_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
}
impl ProfilerServer {
pub fn new(port: u16) -> Self {
let (tx, _) = broadcast::channel(100);
Self {
tx,
port,
shutdown_tx: Arc::new(Mutex::new(None)),
task_handle: Arc::new(Mutex::new(None)),
}
}
pub async fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
let addr = format!("127.0.0.1:{}", self.port);
let listener = TcpListener::bind(&addr).await?;
println!("[ProfilerServer] Listening on: {}", addr);
let tx = self.tx.clone();
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
// 存储 shutdown sender
*self.shutdown_tx.lock().await = Some(shutdown_tx);
// 启动服务器任务
let task = tokio::spawn(async move {
loop {
tokio::select! {
// 监听新连接
result = listener.accept() => {
match result {
Ok((stream, peer_addr)) => {
let tx = tx.clone();
tokio::spawn(handle_connection(stream, peer_addr, tx));
}
Err(e) => {
eprintln!("[ProfilerServer] Failed to accept connection: {}", e);
}
}
}
// 监听关闭信号
_ = &mut shutdown_rx => {
println!("[ProfilerServer] Received shutdown signal");
break;
}
}
}
println!("[ProfilerServer] Server task ending");
});
// 存储任务句柄
*self.task_handle.lock().await = Some(task);
Ok(())
}
pub async fn stop(&self) {
println!("[ProfilerServer] Stopping server...");
// 发送关闭信号
if let Some(shutdown_tx) = self.shutdown_tx.lock().await.take() {
let _ = shutdown_tx.send(());
}
// 等待任务完成
if let Some(handle) = self.task_handle.lock().await.take() {
let _ = handle.await;
}
println!("[ProfilerServer] Server stopped");
}
#[allow(dead_code)]
pub fn broadcast(&self, message: String) {
let _ = self.tx.send(message);
}
}
async fn handle_connection(
stream: TcpStream,
peer_addr: SocketAddr,
tx: broadcast::Sender<String>,
) {
let ws_stream = match accept_async(stream).await {
Ok(ws) => ws,
Err(e) => {
// 忽略非 WebSocket 连接的错误(如普通 HTTP 请求、健康检查等)
// 这些是正常现象,不需要输出错误日志
let error_str = e.to_string();
if !error_str.contains("Connection: upgrade") && !error_str.contains("protocol error") {
eprintln!("[ProfilerServer] WebSocket error: {}", e);
}
return;
}
};
let (mut ws_sender, mut ws_receiver) = ws_stream.split();
let mut rx = tx.subscribe();
println!("[ProfilerServer] Client {} connected", peer_addr);
// Send initial connection confirmation
let _ = ws_sender
.send(Message::Text(
serde_json::json!({
"type": "connected",
"message": "Connected to ECS Editor Profiler"
})
.to_string(),
))
.await;
// Spawn task to forward broadcast messages to this client
let forward_task = tokio::spawn(async move {
while let Ok(msg) = rx.recv().await {
if ws_sender.send(Message::Text(msg)).await.is_err() {
break;
}
}
});
// Handle incoming messages from client
while let Some(msg) = ws_receiver.next().await {
match msg {
Ok(Message::Text(text)) => {
// Parse incoming messages
if let Ok(mut json_value) = serde_json::from_str::<serde_json::Value>(&text) {
let msg_type = json_value.get("type").and_then(|t| t.as_str());
if msg_type == Some("debug_data") {
// Broadcast debug data from game client to all clients (including frontend)
tx.send(text).ok();
} else if msg_type == Some("ping") {
// Respond to ping
let _ = tx.send(
serde_json::json!({
"type": "pong",
"timestamp": chrono::Utc::now().timestamp_millis()
})
.to_string(),
);
} else if msg_type == Some("log") {
// Inject clientId into log messages
if let Some(data) = json_value.get_mut("data").and_then(|d| d.as_object_mut()) {
data.insert("clientId".to_string(), serde_json::Value::String(peer_addr.to_string()));
}
tx.send(json_value.to_string()).ok();
} else {
// Forward all other messages (like get_raw_entity_list, get_entity_details, etc.)
// to all connected clients (this enables frontend -> game client communication)
tx.send(text).ok();
}
}
}
Ok(Message::Close(_)) => {
println!("[ProfilerServer] Client {} disconnected", peer_addr);
break;
}
Ok(Message::Ping(data)) => {
// Respond to WebSocket ping
tx.send(
serde_json::json!({
"type": "pong",
"data": String::from_utf8_lossy(&data)
})
.to_string(),
)
.ok();
}
Err(e) => {
eprintln!("[ProfilerServer] Error: {}", e);
break;
}
_ => {}
}
}
forward_task.abort();
println!("[ProfilerServer] Connection handler ended for {}", peer_addr);
}

View File

@@ -0,0 +1,69 @@
//! Application state definitions.
//! 应用状态定义。
//!
//! Centralized state management for the Tauri application.
//! Tauri 应用的集中状态管理。
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use crate::profiler_ws::ProfilerServer;
/// Project paths state.
/// 项目路径状态。
///
/// Stores the current project path and other path-related information.
/// 存储当前项目路径和其他路径相关信息。
pub type ProjectPaths = Arc<Mutex<HashMap<String, String>>>;
/// Script watcher state.
/// 脚本监视器状态。
///
/// Manages file watchers for hot reload functionality.
/// 管理用于热重载功能的文件监视器。
pub struct ScriptWatcherState {
/// Active watchers keyed by project path | 按项目路径索引的活动监视器
pub watchers: Arc<TokioMutex<HashMap<String, WatcherHandle>>>,
}
/// Handle to a running file watcher.
/// 正在运行的文件监视器句柄。
pub struct WatcherHandle {
/// Shutdown signal sender | 关闭信号发送器
pub shutdown_tx: tokio::sync::oneshot::Sender<()>,
}
impl ScriptWatcherState {
pub fn new() -> Self {
Self {
watchers: Arc::new(TokioMutex::new(HashMap::new())),
}
}
}
impl Default for ScriptWatcherState {
fn default() -> Self {
Self::new()
}
}
/// Profiler server state
///
/// Manages the lifecycle of the WebSocket profiler server.
pub struct ProfilerState {
pub server: Arc<TokioMutex<Option<Arc<ProfilerServer>>>>,
}
impl ProfilerState {
pub fn new() -> Self {
Self {
server: Arc::new(TokioMutex::new(None)),
}
}
}
impl Default for ProfilerState {
fn default() -> Self {
Self::new()
}
}