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
6991
packages/editor/editor-app/src-tauri/Cargo.lock
generated
Normal file
49
packages/editor/editor-app/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "ecs-editor"
|
||||
version = "1.0.0"
|
||||
description = "ESEngine Editor - Cross-platform desktop editor"
|
||||
authors = ["yhh"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ecs_editor_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.0", features = ["protocol-asset"] }
|
||||
tauri-plugin-shell = "2.0"
|
||||
tauri-plugin-dialog = "2.0"
|
||||
tauri-plugin-fs = "2.0"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-http = "2.0"
|
||||
tauri-plugin-cli = "2.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
glob = "0.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
futures-util = "0.3"
|
||||
chrono = "0.4"
|
||||
zip = "0.6"
|
||||
base64 = "0.22"
|
||||
tiny_http = "0.12"
|
||||
once_cell = "1.19"
|
||||
urlencoding = "2.1"
|
||||
qrcode = "0.14"
|
||||
image = "0.25"
|
||||
notify = "7.0"
|
||||
notify-debouncer-mini = "0.5"
|
||||
regex = "1"
|
||||
|
||||
[profile.dev]
|
||||
incremental = true
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
3
packages/editor/editor-app/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
18
packages/editor/editor-app/src-tauri/capabilities/http.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"identifier": "http-capability",
|
||||
"description": "HTTP permissions for GitHub API access and plugin marketplace",
|
||||
"local": true,
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://github.com/**" },
|
||||
{ "url": "https://api.github.com/**" },
|
||||
{ "url": "https://raw.githubusercontent.com/**" },
|
||||
{ "url": "https://cdn.jsdelivr.net/**" },
|
||||
{ "url": "https://fastly.jsdelivr.net/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
packages/editor/editor-app/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 915 B |
BIN
packages/editor/editor-app/src-tauri/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 888 B |
BIN
packages/editor/editor-app/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/icon.icns
Normal file
BIN
packages/editor/editor-app/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
packages/editor/editor-app/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
1
packages/editor/editor-app/src-tauri/icons/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512"><rect width="512" height="512" fill="#1E1E1E"/><defs><radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0%" style="stop-color:#569cd6;stop-opacity:.15"/><stop offset="100%" style="stop-color:#569cd6;stop-opacity:0"/></radialGradient><linearGradient id="cubeGrad1" x1="0%" x2="100%" y1="0%" y2="100%"><stop offset="0%" style="stop-color:#569cd6"/><stop offset="100%" style="stop-color:#4a8bc2"/></linearGradient><linearGradient id="cubeGrad2" x1="0%" x2="0%" y1="0%" y2="100%"><stop offset="0%" style="stop-color:#4ec9b0"/><stop offset="100%" style="stop-color:#3da592"/></linearGradient><filter id="shadow"><feGaussianBlur in="SourceAlpha" stdDeviation="4"/><feOffset dx="2" dy="2" result="offsetblur"/><feComponentTransfer><feFuncA slope=".3" type="linear"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><circle cx="256" cy="256" r="200" fill="url(#glow)"/><g filter="url(#shadow)"><path fill="none" stroke="url(#cubeGrad1)" stroke-width="6" d="M 180 140 L 332 140 L 332 292 L 180 292 Z" opacity=".4"/><path fill="rgba(86, 156, 214, 0.08)" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-linejoin="round" stroke-width="8" d="M 140 180 L 292 180 L 292 332 L 140 332 Z"/><line x1="140" x2="180" y1="180" y2="140" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="292" x2="332" y1="180" y2="140" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="292" x2="332" y1="332" y2="292" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><line x1="140" x2="180" y1="332" y2="292" stroke="url(#cubeGrad1)" stroke-linecap="round" stroke-width="6" opacity=".6"/><circle cx="216" cy="216" r="14" fill="#CE9178" opacity=".95"><animate attributeName="opacity" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><circle cx="256" cy="256" r="14" fill="#4EC9B0" opacity=".95"><animate attributeName="opacity" begin="0.3s" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><circle cx="296" cy="296" r="14" fill="#569CD6" opacity=".95"><animate attributeName="opacity" begin="0.6s" dur="2s" repeatCount="indefinite" values="0.95;1;0.95"/></circle><line x1="216" x2="256" y1="216" y2="256" stroke="#4EC9B0" stroke-linecap="round" stroke-width="2" opacity=".5"/><line x1="256" x2="296" y1="256" y2="296" stroke="#569CD6" stroke-linecap="round" stroke-width="2" opacity=".5"/></g><g stroke="#4EC9B0" stroke-linecap="round" stroke-width="3" opacity=".3"><line x1="130" x2="130" y1="170" y2="190"/><line x1="130" x2="150" y1="170" y2="170"/><line x1="302" x2="302" y1="342" y2="322"/><line x1="302" x2="282" y1="342" y2="342"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 847 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
535
packages/editor/editor-app/src-tauri/src/commands/build.rs
Normal 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))
|
||||
}
|
||||
1107
packages/editor/editor-app/src-tauri/src/commands/compiler.rs
Normal file
107
packages/editor/editor-app/src-tauri/src/commands/dialog.rs
Normal 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()))
|
||||
}
|
||||
291
packages/editor/editor-app/src-tauri/src/commands/file_system.rs
Normal 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(())
|
||||
}
|
||||
26
packages/editor/editor-app/src-tauri/src/commands/mod.rs
Normal 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::*;
|
||||
193
packages/editor/editor-app/src-tauri/src/commands/modules.rs
Normal 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())
|
||||
}
|
||||
270
packages/editor/editor-app/src-tauri/src/commands/plugin.rs
Normal 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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
77
packages/editor/editor-app/src-tauri/src/commands/project.rs
Normal 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(())
|
||||
}
|
||||
749
packages/editor/editor-app/src-tauri/src/commands/system.rs
Normal 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))
|
||||
}
|
||||
10
packages/editor/editor-app/src-tauri/src/lib.rs
Normal 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};
|
||||
212
packages/editor/editor-app/src-tauri/src/main.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
193
packages/editor/editor-app/src-tauri/src/profiler_ws.rs
Normal 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);
|
||||
}
|
||||
69
packages/editor/editor-app/src-tauri/src/state.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
162
packages/editor/editor-app/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"productName": "ESEngine Editor",
|
||||
"version": "1.0.14",
|
||||
"identifier": "com.esengine.editor",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run build:watch",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": [
|
||||
"runtime/**/*",
|
||||
"engine/**/*",
|
||||
"bin/*"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "",
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": [
|
||||
"ecs"
|
||||
],
|
||||
"name": "ECS Scene File",
|
||||
"description": "ESEngine Scene File",
|
||||
"role": "Editor"
|
||||
}
|
||||
],
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "10.13",
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"providerShortName": null,
|
||||
"entitlements": null
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "ESEngine Editor",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"skipTaskbar": false,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' tauri: project: asset: https: http: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' tauri: project: asset: https: http: blob:; style-src 'self' 'unsafe-inline' tauri: https: http:; img-src 'self' tauri: project: asset: https: http: data: blob:; connect-src 'self' tauri: project: asset: https: http: ws: wss:",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": {
|
||||
"allow": [
|
||||
"**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "main",
|
||||
"windows": [
|
||||
"main",
|
||||
"frame-debugger"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"shell:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-read",
|
||||
"fs:allow-write",
|
||||
"fs:allow-create",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-remove",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-copy-file"
|
||||
],
|
||||
"scope": {
|
||||
"allow": [
|
||||
"$HOME/**",
|
||||
"$APPDATA/**",
|
||||
"$APPLOCALDATA/**",
|
||||
"$APPCACHE/**",
|
||||
"$APPLOG/**",
|
||||
"$DESKTOP/**",
|
||||
"$DOCUMENT/**",
|
||||
"$DOWNLOAD/**",
|
||||
"$TEMP/**"
|
||||
]
|
||||
}
|
||||
},
|
||||
"http-capability"
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"cli": {
|
||||
"args": [
|
||||
{
|
||||
"name": "file",
|
||||
"index": 1,
|
||||
"takesValue": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
"endpoints": [
|
||||
"https://github.com/esengine/esengine/releases/latest/download/latest.json"
|
||||
],
|
||||
"dialog": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFDQjNFNDIxREFBODNDNkMKUldSc1BLamFJZVN6SEJIRXRWWEovVXRta08yNWFkZmtKNnZoSHFmbi9ZdGxubUMzSHJaN3J0VEcK"
|
||||
}
|
||||
}
|
||||
}
|
||||