Feature/runtime cdn and plugin loader (#240)
* feat(ui): 完善 UI 布局系统和编辑器可视化工具 * refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统 * fix: 修复 CodeQL 警告并提升测试覆盖率 * refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题 * fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤 * docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明 * fix(ci): 修复 type-check 失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖 * fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖 * fix(ci): platform-web 添加缺失的 behavior-tree 依赖 * fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
@@ -3,13 +3,13 @@
|
||||
* 管理Rust引擎生命周期的服务。
|
||||
*/
|
||||
|
||||
import { EngineBridge, EngineRenderSystem, CameraConfig, GizmoDataProviderFn, HasGizmoProviderFn } from '@esengine/ecs-engine-bindgen';
|
||||
import { GizmoRegistry } from '@esengine/editor-core';
|
||||
import { EngineBridge, EngineRenderSystem, GizmoDataProviderFn, HasGizmoProviderFn, CameraConfig, CameraSystem } from '@esengine/ecs-engine-bindgen';
|
||||
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, type SystemContext } from '@esengine/editor-core';
|
||||
import { Core, Scene, Entity, SceneSerializer } from '@esengine/ecs-framework';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorSystem, SpriteAnimatorComponent } from '@esengine/ecs-components';
|
||||
import { TransformComponent, SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystem } from '@esengine/ecs-components';
|
||||
import { TilemapComponent, TilemapRenderingSystem } from '@esengine/tilemap';
|
||||
import { UIRenderDataProvider } from '@esengine/ui';
|
||||
import { EntityStoreService, MessageHub, SceneManagerService, ProjectService } from '@esengine/editor-core';
|
||||
import { BehaviorTreeExecutionSystem } from '@esengine/behavior-tree';
|
||||
import { UIRenderDataProvider, invalidateUIRenderCaches } from '@esengine/ui';
|
||||
import * as esEngine from '@esengine/engine';
|
||||
import {
|
||||
AssetManager,
|
||||
@@ -32,10 +32,13 @@ export class EngineService {
|
||||
private bridge: EngineBridge | null = null;
|
||||
private scene: Scene | null = null;
|
||||
private renderSystem: EngineRenderSystem | null = null;
|
||||
private cameraSystem: CameraSystem | null = null;
|
||||
private animatorSystem: SpriteAnimatorSystem | null = null;
|
||||
private tilemapSystem: TilemapRenderingSystem | null = null;
|
||||
private behaviorTreeSystem: BehaviorTreeExecutionSystem | null = null;
|
||||
private uiRenderProvider: UIRenderDataProvider | null = null;
|
||||
private initialized = false;
|
||||
private modulesInitialized = false;
|
||||
private running = false;
|
||||
private animationFrameId: number | null = null;
|
||||
private lastTime = 0;
|
||||
@@ -46,6 +49,7 @@ export class EngineService {
|
||||
private assetPathResolver: AssetPathResolver | null = null;
|
||||
private assetSystemInitialized = false;
|
||||
private initializationError: Error | null = null;
|
||||
private canvasId: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -60,15 +64,37 @@ export class EngineService {
|
||||
return EngineService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待引擎初始化完成
|
||||
* @param timeout 超时时间(毫秒),默认 10 秒
|
||||
*/
|
||||
async waitForInitialization(timeout = 10000): Promise<boolean> {
|
||||
if (this.initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
while (!this.initialized && Date.now() - startTime < timeout) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the engine with canvas.
|
||||
* 使用canvas初始化引擎。
|
||||
*
|
||||
* 注意:此方法只初始化引擎基础设施(Core、渲染系统等),
|
||||
* 模块的初始化需要在项目打开后调用 initializeModuleSystems()
|
||||
*/
|
||||
async initialize(canvasId: string): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.canvasId = canvasId;
|
||||
|
||||
try {
|
||||
// Create engine bridge | 创建引擎桥接
|
||||
this.bridge = new EngineBridge({
|
||||
@@ -96,7 +122,7 @@ export class EngineService {
|
||||
Core.create({ debug: false });
|
||||
}
|
||||
|
||||
// Use existing Core scene or create new one | 使用现有Core场景或创建新的
|
||||
// 使用现有 Core 场景或创建新的
|
||||
if (Core.scene) {
|
||||
this.scene = Core.scene as Scene;
|
||||
} else {
|
||||
@@ -104,38 +130,15 @@ export class EngineService {
|
||||
Core.setScene(this.scene);
|
||||
}
|
||||
|
||||
// Add sprite animator system (disabled by default in editor mode)
|
||||
// 添加精灵动画系统(编辑器模式下默认禁用)
|
||||
this.animatorSystem = new SpriteAnimatorSystem();
|
||||
this.animatorSystem.enabled = false;
|
||||
this.scene!.addSystem(this.animatorSystem);
|
||||
// Add camera system (基础系统,始终需要)
|
||||
this.cameraSystem = new CameraSystem(this.bridge);
|
||||
this.scene.addSystem(this.cameraSystem);
|
||||
|
||||
// Add tilemap rendering system
|
||||
// 添加瓦片地图渲染系统
|
||||
this.tilemapSystem = new TilemapRenderingSystem();
|
||||
this.scene!.addSystem(this.tilemapSystem);
|
||||
|
||||
// Add render system to the scene | 将渲染系统添加到场景
|
||||
// Add render system to the scene (基础系统,始终需要)
|
||||
this.renderSystem = new EngineRenderSystem(this.bridge, TransformComponent);
|
||||
this.scene!.addSystem(this.renderSystem);
|
||||
|
||||
// Register tilemap system as render data provider
|
||||
// 将瓦片地图系统注册为渲染数据提供者
|
||||
this.renderSystem.addRenderDataProvider(this.tilemapSystem);
|
||||
|
||||
// Register UI render data provider
|
||||
// 注册 UI 渲染数据提供者
|
||||
this.uiRenderProvider = new UIRenderDataProvider();
|
||||
this.renderSystem.addRenderDataProvider(this.uiRenderProvider);
|
||||
|
||||
// Set up texture callback for UI text rendering
|
||||
// 设置 UI 文本渲染的纹理回调
|
||||
this.uiRenderProvider.setTextureCallback((id: number, dataUrl: string) => {
|
||||
this.bridge!.loadTexture(id, dataUrl);
|
||||
});
|
||||
this.scene.addSystem(this.renderSystem);
|
||||
|
||||
// Inject GizmoRegistry into render system
|
||||
// 将 GizmoRegistry 注入渲染系统
|
||||
this.renderSystem.setGizmoRegistry(
|
||||
((component, entity, isSelected) =>
|
||||
GizmoRegistry.getGizmoData(component, entity, isSelected)) as GizmoDataProviderFn,
|
||||
@@ -143,6 +146,10 @@ export class EngineService {
|
||||
GizmoRegistry.hasProvider(component.constructor as any)) as HasGizmoProviderFn
|
||||
);
|
||||
|
||||
// Set initial UI canvas size (will be updated from ProjectService when project opens)
|
||||
// 设置初始 UI 画布尺寸(项目打开后会从 ProjectService 更新为项目配置的分辨率)
|
||||
this.renderSystem.setUICanvasSize(1920, 1080);
|
||||
|
||||
// Initialize asset system | 初始化资产系统
|
||||
await this.initializeAssetSystem();
|
||||
|
||||
@@ -184,6 +191,107 @@ export class EngineService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模块系统
|
||||
* Initialize module systems for all enabled plugins
|
||||
*
|
||||
* 通过 PluginManager 初始化所有插件的运行时模块
|
||||
* Initialize all plugin runtime modules via PluginManager
|
||||
*/
|
||||
async initializeModuleSystems(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
console.error('Engine not initialized. Call initialize() first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.scene || !this.renderSystem || !this.bridge) {
|
||||
console.error('Scene or render system not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果之前已经初始化过模块,先清理
|
||||
if (this.modulesInitialized) {
|
||||
this.clearModuleSystems();
|
||||
}
|
||||
|
||||
// 获取 PluginManager
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (!pluginManager) {
|
||||
console.error('PluginManager not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化所有插件的运行时模块(注册组件和服务)
|
||||
// Initialize all plugin runtime modules (register components and services)
|
||||
await pluginManager.initializeRuntime(Core.services);
|
||||
|
||||
// 创建系统上下文
|
||||
// Create system context
|
||||
const context: SystemContext = {
|
||||
core: Core,
|
||||
engineBridge: this.bridge,
|
||||
renderSystem: this.renderSystem,
|
||||
isEditor: true
|
||||
};
|
||||
|
||||
// 让插件为场景创建系统
|
||||
// Let plugins create systems for scene
|
||||
pluginManager.createSystemsForScene(this.scene, context);
|
||||
|
||||
// 保存插件创建的系统引用
|
||||
// Save system references created by plugins
|
||||
this.animatorSystem = context.animatorSystem as SpriteAnimatorSystem | undefined ?? null;
|
||||
this.tilemapSystem = context.tilemapSystem as TilemapRenderingSystem | undefined ?? null;
|
||||
this.behaviorTreeSystem = context.behaviorTreeSystem as BehaviorTreeExecutionSystem | undefined ?? null;
|
||||
this.uiRenderProvider = context.uiRenderProvider as UIRenderDataProvider | undefined ?? null;
|
||||
|
||||
// 设置 UI 渲染数据提供者到 EngineRenderSystem
|
||||
// Set UI render data provider to EngineRenderSystem
|
||||
if (this.uiRenderProvider && this.renderSystem) {
|
||||
this.renderSystem.setUIRenderDataProvider(this.uiRenderProvider);
|
||||
}
|
||||
|
||||
// 在编辑器模式下,动画和行为树系统默认禁用
|
||||
// In editor mode, animation and behavior tree systems are disabled by default
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = false;
|
||||
}
|
||||
if (this.behaviorTreeSystem) {
|
||||
this.behaviorTreeSystem.enabled = false;
|
||||
}
|
||||
|
||||
this.modulesInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理模块系统
|
||||
* 用于项目关闭或切换时
|
||||
* Clear module systems, used when project closes or switches
|
||||
*/
|
||||
clearModuleSystems(): void {
|
||||
// 通过 PluginManager 清理场景系统
|
||||
// Clear scene systems via PluginManager
|
||||
const pluginManager = Core.services.tryResolve<PluginManager>(IPluginManager);
|
||||
if (pluginManager) {
|
||||
pluginManager.clearSceneSystems();
|
||||
}
|
||||
|
||||
// 清空本地引用(系统的实际清理由场景管理)
|
||||
// Clear local references (actual system cleanup is managed by scene)
|
||||
this.animatorSystem = null;
|
||||
this.tilemapSystem = null;
|
||||
this.behaviorTreeSystem = null;
|
||||
this.uiRenderProvider = null;
|
||||
this.modulesInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模块系统是否已初始化
|
||||
*/
|
||||
isModulesInitialized(): boolean {
|
||||
return this.modulesInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start render loop (editor preview mode).
|
||||
* 启动渲染循环(编辑器预览模式)。
|
||||
@@ -248,11 +356,22 @@ export class EngineService {
|
||||
this.running = true;
|
||||
this.lastTime = performance.now();
|
||||
|
||||
// Enable preview mode for UI rendering (screen space overlay)
|
||||
// 启用预览模式用于 UI 渲染(屏幕空间叠加)
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setPreviewMode(true);
|
||||
}
|
||||
|
||||
// Enable animator system and start auto-play animations
|
||||
// 启用动画系统并启动自动播放的动画
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = true;
|
||||
}
|
||||
// Enable behavior tree system for preview
|
||||
// 启用行为树系统用于预览
|
||||
if (this.behaviorTreeSystem) {
|
||||
this.behaviorTreeSystem.enabled = true;
|
||||
}
|
||||
this.startAutoPlayAnimations();
|
||||
|
||||
this.gameLoop();
|
||||
@@ -310,11 +429,22 @@ export class EngineService {
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
|
||||
// Disable preview mode for UI rendering (back to world space)
|
||||
// 禁用预览模式用于 UI 渲染(返回世界空间)
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setPreviewMode(false);
|
||||
}
|
||||
|
||||
// Disable animator system and stop all animations
|
||||
// 禁用动画系统并停止所有动画
|
||||
if (this.animatorSystem) {
|
||||
this.animatorSystem.enabled = false;
|
||||
}
|
||||
// Disable behavior tree system
|
||||
// 禁用行为树系统
|
||||
if (this.behaviorTreeSystem) {
|
||||
this.behaviorTreeSystem.enabled = false;
|
||||
}
|
||||
this.stopAllAnimations();
|
||||
|
||||
// Note: Don't cancel animationFrameId here, as renderLoop should keep running
|
||||
@@ -669,6 +799,42 @@ export class EngineService {
|
||||
return this.renderSystem?.getShowGizmos() ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set UI canvas size for boundary display.
|
||||
* 设置 UI 画布尺寸以显示边界。
|
||||
*/
|
||||
setUICanvasSize(width: number, height: number): void {
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setUICanvasSize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI canvas size.
|
||||
* 获取 UI 画布尺寸。
|
||||
*/
|
||||
getUICanvasSize(): { width: number; height: number } {
|
||||
return this.renderSystem?.getUICanvasSize() ?? { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set UI canvas boundary visibility.
|
||||
* 设置 UI 画布边界可见性。
|
||||
*/
|
||||
setShowUICanvasBoundary(show: boolean): void {
|
||||
if (this.renderSystem) {
|
||||
this.renderSystem.setShowUICanvasBoundary(show);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI canvas boundary visibility.
|
||||
* 获取 UI 画布边界可见性。
|
||||
*/
|
||||
getShowUICanvasBoundary(): boolean {
|
||||
return this.renderSystem?.getShowUICanvasBoundary() ?? true;
|
||||
}
|
||||
|
||||
// ===== Scene Snapshot API =====
|
||||
// ===== 场景快照 API =====
|
||||
|
||||
@@ -711,24 +877,18 @@ export class EngineService {
|
||||
// Clear tilemap rendering cache before restoring
|
||||
// 恢复前清除瓦片地图渲染缓存
|
||||
if (this.tilemapSystem) {
|
||||
console.log('[EngineService] Clearing tilemap cache before restore');
|
||||
this.tilemapSystem.clearCache();
|
||||
}
|
||||
|
||||
// Clear UI text cache before restoring
|
||||
// 恢复前清除 UI 文本缓存
|
||||
if (this.uiRenderProvider) {
|
||||
console.log('[EngineService] Clearing UI text cache before restore');
|
||||
this.uiRenderProvider.clearTextCache();
|
||||
}
|
||||
// Clear UI render caches before restoring
|
||||
// 恢复前清除 UI 渲染缓存
|
||||
invalidateUIRenderCaches();
|
||||
|
||||
// Use SceneSerializer from core library
|
||||
console.log('[EngineService] Deserializing scene snapshot');
|
||||
SceneSerializer.deserialize(this.scene, this.sceneSnapshot, {
|
||||
strategy: 'replace',
|
||||
preserveIds: true
|
||||
});
|
||||
console.log('[EngineService] Scene deserialized, entities:', this.scene.entities.buffer.length);
|
||||
|
||||
// 加载场景资源 / Load scene resources
|
||||
if (this.sceneResourceManager) {
|
||||
@@ -762,7 +922,6 @@ export class EngineService {
|
||||
}
|
||||
|
||||
// Notify UI to refresh
|
||||
console.log('[EngineService] Publishing scene:restored event');
|
||||
messageHub.publish('scene:restored', {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
interface ImportMap {
|
||||
imports: Record<string, string>;
|
||||
scopes?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
const SDK_MODULES: Record<string, string> = {
|
||||
'@esengine/editor-runtime': 'editor-runtime.js',
|
||||
'@esengine/behavior-tree': 'behavior-tree.js',
|
||||
};
|
||||
|
||||
class ImportMapManager {
|
||||
private initialized = false;
|
||||
private importMap: ImportMap = { imports: {} };
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.buildImportMap();
|
||||
this.injectImportMap();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[ImportMapManager] Import Map initialized:', this.importMap);
|
||||
}
|
||||
|
||||
private async buildImportMap(): Promise<void> {
|
||||
const baseUrl = this.getBaseUrl();
|
||||
|
||||
for (const [moduleName, fileName] of Object.entries(SDK_MODULES)) {
|
||||
this.importMap.imports[moduleName] = `${baseUrl}assets/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
return window.location.origin + '/';
|
||||
}
|
||||
|
||||
private injectImportMap(): void {
|
||||
const existingMap = document.querySelector('script[type="importmap"]');
|
||||
if (existingMap) {
|
||||
try {
|
||||
const existing = JSON.parse(existingMap.textContent || '{}');
|
||||
this.importMap.imports = { ...existing.imports, ...this.importMap.imports };
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
existingMap.remove();
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.type = 'importmap';
|
||||
script.textContent = JSON.stringify(this.importMap, null, 2);
|
||||
|
||||
const head = document.head;
|
||||
const firstScript = head.querySelector('script');
|
||||
if (firstScript) {
|
||||
head.insertBefore(script, firstScript);
|
||||
} else {
|
||||
head.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
getImportMap(): ImportMap {
|
||||
return { ...this.importMap };
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
export const importMapManager = new ImportMapManager();
|
||||
@@ -1,8 +1,13 @@
|
||||
import { EditorPluginManager, LocaleService, MessageHub } from '@esengine/editor-core';
|
||||
import type { IEditorPlugin } from '@esengine/editor-core';
|
||||
/**
|
||||
* 项目插件加载器
|
||||
* Project Plugin Loader
|
||||
*/
|
||||
|
||||
import { PluginManager, LocaleService, MessageHub } from '@esengine/editor-core';
|
||||
import type { IPluginLoader, PluginDescriptor } from '@esengine/editor-core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
import { importMapManager } from './ImportMapManager';
|
||||
import { PluginSDKRegistry } from './PluginSDKRegistry';
|
||||
|
||||
interface PluginPackageJson {
|
||||
name: string;
|
||||
@@ -18,36 +23,45 @@ interface PluginPackageJson {
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件加载器
|
||||
* 插件元数据(用于卸载时清理)
|
||||
*/
|
||||
interface LoadedPluginMeta {
|
||||
name: string;
|
||||
scriptElement?: HTMLScriptElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目插件加载器
|
||||
*
|
||||
* 负责从项目的 plugins 目录加载用户插件。
|
||||
* 统一使用 project:// 协议加载预编译的 JS 文件。
|
||||
* 使用全局变量方案加载插件:
|
||||
* 1. 插件构建时将 @esengine/* 标记为 external
|
||||
* 2. 插件输出为 IIFE 格式,依赖从 window.__ESENGINE__ 获取
|
||||
* 3. 插件导出到 window.__ESENGINE_PLUGINS__[pluginName]
|
||||
*/
|
||||
export class PluginLoader {
|
||||
private loadedPluginNames: Set<string> = new Set();
|
||||
private moduleVersions: Map<string, number> = new Map();
|
||||
private loadedPlugins: Map<string, LoadedPluginMeta> = new Map();
|
||||
|
||||
/**
|
||||
* 加载项目中的所有插件
|
||||
*/
|
||||
async loadProjectPlugins(projectPath: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||
// 确保 Import Map 已初始化
|
||||
await importMapManager.initialize();
|
||||
async loadProjectPlugins(projectPath: string, pluginManager: PluginManager): Promise<void> {
|
||||
// 确保 SDK 已注册到全局
|
||||
PluginSDKRegistry.initialize();
|
||||
|
||||
// 初始化插件容器
|
||||
this.initPluginContainer();
|
||||
|
||||
const pluginsPath = `${projectPath}/plugins`;
|
||||
|
||||
try {
|
||||
const exists = await TauriAPI.pathExists(pluginsPath);
|
||||
if (!exists) {
|
||||
console.log('[PluginLoader] No plugins directory found');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await TauriAPI.listDirectory(pluginsPath);
|
||||
const pluginDirs = entries.filter((entry) => entry.is_dir && !entry.name.startsWith('.'));
|
||||
|
||||
console.log(`[PluginLoader] Found ${pluginDirs.length} plugin(s)`);
|
||||
|
||||
for (const entry of pluginDirs) {
|
||||
const pluginPath = `${pluginsPath}/${entry.name}`;
|
||||
await this.loadPlugin(pluginPath, entry.name, pluginManager);
|
||||
@@ -57,13 +71,22 @@ export class PluginLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化插件容器
|
||||
*/
|
||||
private initPluginContainer(): void {
|
||||
if (!window.__ESENGINE_PLUGINS__) {
|
||||
window.__ESENGINE_PLUGINS__ = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载单个插件
|
||||
*/
|
||||
private async loadPlugin(
|
||||
pluginPath: string,
|
||||
pluginDirName: string,
|
||||
pluginManager: EditorPluginManager
|
||||
pluginManager: PluginManager
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. 读取 package.json
|
||||
@@ -72,12 +95,13 @@ export class PluginLoader {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 如果插件已加载,先卸载
|
||||
if (this.loadedPluginNames.has(packageJson.name)) {
|
||||
await this.unloadPlugin(packageJson.name, pluginManager);
|
||||
// 2. 如果插件已加载,跳过
|
||||
if (this.loadedPlugins.has(packageJson.name)) {
|
||||
console.warn(`[PluginLoader] Plugin ${packageJson.name} already loaded`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 确定入口文件(必须是编译后的 JS)
|
||||
// 3. 确定入口文件
|
||||
const entryPoint = this.resolveEntryPoint(packageJson);
|
||||
|
||||
// 4. 验证文件存在
|
||||
@@ -89,27 +113,35 @@ export class PluginLoader {
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 构建模块 URL(使用 project:// 协议)
|
||||
const moduleUrl = this.buildModuleUrl(pluginDirName, entryPoint, packageJson.name);
|
||||
console.log(`[PluginLoader] Loading: ${packageJson.name} from ${moduleUrl}`);
|
||||
// 5. 读取插件代码
|
||||
const pluginCode = await TauriAPI.readFileContent(fullPath);
|
||||
|
||||
// 6. 动态导入模块
|
||||
const module = await import(/* @vite-ignore */ moduleUrl);
|
||||
// 6. 执行插件代码(CSS 应内联到 JS 中,会自动注入)
|
||||
const pluginLoader = await this.executePluginCode(
|
||||
pluginCode,
|
||||
packageJson.name,
|
||||
pluginDirName
|
||||
);
|
||||
|
||||
// 7. 查找并验证插件实例
|
||||
const pluginInstance = this.findPluginInstance(module);
|
||||
if (!pluginInstance) {
|
||||
console.error(`[PluginLoader] No valid plugin instance found in ${packageJson.name}`);
|
||||
if (!pluginLoader) {
|
||||
console.error(`[PluginLoader] No valid plugin found in ${packageJson.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. 安装插件
|
||||
await pluginManager.installEditor(pluginInstance);
|
||||
this.loadedPluginNames.add(packageJson.name);
|
||||
console.log(`[PluginLoader] Successfully loaded: ${packageJson.name}`);
|
||||
// 7. 注册插件
|
||||
pluginManager.register(pluginLoader);
|
||||
|
||||
// 9. 同步语言设置
|
||||
this.syncPluginLocale(pluginInstance, packageJson.name);
|
||||
// 8. 初始化编辑器模块(注册面板、文件处理器等)
|
||||
const pluginId = pluginLoader.descriptor.id;
|
||||
await pluginManager.initializePluginEditor(pluginId, Core.services);
|
||||
|
||||
// 9. 记录已加载
|
||||
this.loadedPlugins.set(packageJson.name, {
|
||||
name: packageJson.name,
|
||||
});
|
||||
|
||||
// 10. 同步语言设置
|
||||
this.syncPluginLocale(pluginLoader, packageJson.name);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to load plugin from ${pluginPath}:`, error);
|
||||
@@ -119,6 +151,82 @@ export class PluginLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行插件代码并返回插件加载器
|
||||
*/
|
||||
private async executePluginCode(
|
||||
code: string,
|
||||
pluginName: string,
|
||||
_pluginDirName: string
|
||||
): Promise<IPluginLoader | null> {
|
||||
const pluginKey = this.sanitizePluginKey(pluginName);
|
||||
|
||||
try {
|
||||
// 插件代码是 IIFE 格式,会自动导出到 window.__ESENGINE_PLUGINS__
|
||||
await this.executeViaScriptTag(code, pluginName);
|
||||
|
||||
// 从全局容器获取插件模块
|
||||
const pluginModule = window.__ESENGINE_PLUGINS__[pluginKey];
|
||||
if (!pluginModule) {
|
||||
// 尝试其他可能的 key 格式
|
||||
const altKeys = Object.keys(window.__ESENGINE_PLUGINS__).filter(k =>
|
||||
k.includes(pluginName.replace(/@/g, '').replace(/\//g, '_').replace(/-/g, '_'))
|
||||
);
|
||||
|
||||
if (altKeys.length > 0 && altKeys[0] !== undefined) {
|
||||
const foundKey = altKeys[0];
|
||||
const altModule = window.__ESENGINE_PLUGINS__[foundKey];
|
||||
return this.findPluginLoader(altModule);
|
||||
}
|
||||
|
||||
console.error(`[PluginLoader] Plugin ${pluginName} did not export to __ESENGINE_PLUGINS__`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.findPluginLoader(pluginModule);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to execute plugin code for ${pluginName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 script 标签执行代码
|
||||
*/
|
||||
private executeViaScriptTag(code: string, pluginName: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pluginKey = this.sanitizePluginKey(pluginName);
|
||||
|
||||
const blob = new Blob([code], { type: 'application/javascript' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = `plugin-${pluginKey}`;
|
||||
script.async = false;
|
||||
|
||||
script.onload = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = (e) => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
reject(new Error(`Script load failed: ${e}`));
|
||||
};
|
||||
|
||||
script.src = blobUrl;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理插件名称为有效的 key
|
||||
*/
|
||||
private sanitizePluginKey(pluginName: string): string {
|
||||
return pluginName.replace(/[@/\-.]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取插件的 package.json
|
||||
*/
|
||||
@@ -136,7 +244,7 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析插件入口文件路径(始终使用编译后的 JS)
|
||||
* 解析插件入口文件路径
|
||||
*/
|
||||
private resolveEntryPoint(packageJson: PluginPackageJson): string {
|
||||
const entry = (
|
||||
@@ -149,41 +257,18 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模块 URL(使用 project:// 协议)
|
||||
*
|
||||
* Windows 上需要用 http://project.localhost/ 格式
|
||||
* macOS/Linux 上用 project://localhost/ 格式
|
||||
* 查找模块中的插件加载器
|
||||
*/
|
||||
private buildModuleUrl(pluginDirName: string, entryPoint: string, pluginName: string): string {
|
||||
// 版本号 + 时间戳确保每次加载都是新模块(绕过浏览器缓存)
|
||||
const version = (this.moduleVersions.get(pluginName) || 0) + 1;
|
||||
this.moduleVersions.set(pluginName, version);
|
||||
const timestamp = Date.now();
|
||||
|
||||
const path = `/plugins/${pluginDirName}/${entryPoint}?v=${version}&t=${timestamp}`;
|
||||
|
||||
// Windows 使用 http://scheme.localhost 格式
|
||||
// macOS/Linux 使用 scheme://localhost 格式
|
||||
const isWindows = navigator.userAgent.includes('Windows');
|
||||
if (isWindows) {
|
||||
return `http://project.localhost${path}`;
|
||||
}
|
||||
return `project://localhost${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找模块中的插件实例
|
||||
*/
|
||||
private findPluginInstance(module: any): IEditorPlugin | null {
|
||||
private findPluginLoader(module: any): IPluginLoader | null {
|
||||
// 优先检查 default 导出
|
||||
if (module.default && this.isPluginInstance(module.default)) {
|
||||
if (module.default && this.isPluginLoader(module.default)) {
|
||||
return module.default;
|
||||
}
|
||||
|
||||
// 检查命名导出
|
||||
// 检查命名导出(常见的命名:Plugin, XXXPlugin)
|
||||
for (const key of Object.keys(module)) {
|
||||
const value = module[key];
|
||||
if (value && this.isPluginInstance(value)) {
|
||||
if (value && this.isPluginLoader(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -192,33 +277,43 @@ export class PluginLoader {
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象是否为有效的插件实例
|
||||
* 验证对象是否为有效的插件加载器
|
||||
*/
|
||||
private isPluginInstance(obj: any): obj is IEditorPlugin {
|
||||
private isPluginLoader(obj: any): obj is IPluginLoader {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 新的 IPluginLoader 接口检查
|
||||
if (obj.descriptor && this.isPluginDescriptor(obj.descriptor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证对象是否为有效的插件描述符
|
||||
*/
|
||||
private isPluginDescriptor(obj: any): obj is PluginDescriptor {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj.id === 'string' &&
|
||||
typeof obj.name === 'string' &&
|
||||
typeof obj.version === 'string' &&
|
||||
typeof obj.displayName === 'string' &&
|
||||
typeof obj.category === 'string' &&
|
||||
typeof obj.install === 'function' &&
|
||||
typeof obj.uninstall === 'function'
|
||||
typeof obj.version === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步插件语言设置
|
||||
*/
|
||||
private syncPluginLocale(plugin: IEditorPlugin, pluginName: string): void {
|
||||
private syncPluginLocale(plugin: IPluginLoader, pluginName: string): void {
|
||||
try {
|
||||
const localeService = Core.services.resolve(LocaleService);
|
||||
const currentLocale = localeService.getCurrentLocale();
|
||||
|
||||
if (plugin.setLocale) {
|
||||
plugin.setLocale(currentLocale);
|
||||
if (plugin.editorModule?.setLocale) {
|
||||
plugin.editorModule.setLocale(currentLocale);
|
||||
}
|
||||
|
||||
// 通知 UI 刷新
|
||||
@@ -229,33 +324,37 @@ export class PluginLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载单个插件
|
||||
*/
|
||||
private async unloadPlugin(pluginName: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||
try {
|
||||
await pluginManager.uninstallEditor(pluginName);
|
||||
this.loadedPluginNames.delete(pluginName);
|
||||
console.log(`[PluginLoader] Unloaded: ${pluginName}`);
|
||||
} catch (error) {
|
||||
console.error(`[PluginLoader] Failed to unload ${pluginName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载所有已加载的插件
|
||||
*/
|
||||
async unloadProjectPlugins(pluginManager: EditorPluginManager): Promise<void> {
|
||||
for (const pluginName of this.loadedPluginNames) {
|
||||
await this.unloadPlugin(pluginName, pluginManager);
|
||||
async unloadProjectPlugins(_pluginManager: PluginManager): Promise<void> {
|
||||
for (const pluginName of this.loadedPlugins.keys()) {
|
||||
// 清理全局容器中的插件
|
||||
const pluginKey = this.sanitizePluginKey(pluginName);
|
||||
if (window.__ESENGINE_PLUGINS__?.[pluginKey]) {
|
||||
delete window.__ESENGINE_PLUGINS__[pluginKey];
|
||||
}
|
||||
|
||||
// 移除 script 标签
|
||||
const scriptEl = document.getElementById(`plugin-${pluginKey}`);
|
||||
if (scriptEl) {
|
||||
scriptEl.remove();
|
||||
}
|
||||
}
|
||||
this.loadedPluginNames.clear();
|
||||
this.loadedPlugins.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的插件名称列表
|
||||
*/
|
||||
getLoadedPluginNames(): string[] {
|
||||
return Array.from(this.loadedPluginNames);
|
||||
return Array.from(this.loadedPlugins.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// 全局类型声明
|
||||
declare global {
|
||||
interface Window {
|
||||
__ESENGINE_PLUGINS__: Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import type { EditorPluginManager } from '@esengine/editor-core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface PluginAuthor {
|
||||
name: string;
|
||||
github: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface PluginRepository {
|
||||
type?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PluginVersion {
|
||||
version: string;
|
||||
releaseDate: string;
|
||||
changes: string;
|
||||
zipUrl: string;
|
||||
requirements: PluginRequirements;
|
||||
}
|
||||
|
||||
export interface PluginRequirements {
|
||||
'ecs-version': string;
|
||||
'editor-version'?: string;
|
||||
}
|
||||
|
||||
export interface PluginMarketMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
author: PluginAuthor;
|
||||
description: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
icon?: string;
|
||||
repository: PluginRepository;
|
||||
license: string;
|
||||
homepage?: string;
|
||||
screenshots?: string[];
|
||||
latestVersion: string;
|
||||
versions: PluginVersion[];
|
||||
verified?: boolean;
|
||||
category_type?: 'official' | 'community';
|
||||
}
|
||||
|
||||
export interface PluginRegistry {
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
cdn: string;
|
||||
plugins: PluginMarketMetadata[];
|
||||
}
|
||||
|
||||
interface InstalledPluginInfo {
|
||||
id: string;
|
||||
version: string;
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
export class PluginMarketService {
|
||||
private readonly REGISTRY_URLS = [
|
||||
'https://cdn.jsdelivr.net/gh/esengine/ecs-editor-plugins@gh-pages/registry.json',
|
||||
'https://raw.githubusercontent.com/esengine/ecs-editor-plugins/gh-pages/registry.json',
|
||||
'https://fastly.jsdelivr.net/gh/esengine/ecs-editor-plugins@gh-pages/registry.json'
|
||||
];
|
||||
|
||||
private readonly GITHUB_DIRECT_URL = 'https://raw.githubusercontent.com/esengine/ecs-editor-plugins/gh-pages/registry.json';
|
||||
|
||||
private readonly STORAGE_KEY = 'ecs-editor-installed-marketplace-plugins';
|
||||
private readonly USE_DIRECT_SOURCE_KEY = 'ecs-editor-use-direct-source';
|
||||
|
||||
private pluginManager: EditorPluginManager;
|
||||
private installedPlugins: Map<string, InstalledPluginInfo> = new Map();
|
||||
private projectPath: string | null = null;
|
||||
|
||||
constructor(pluginManager: EditorPluginManager) {
|
||||
this.pluginManager = pluginManager;
|
||||
this.loadInstalledPlugins();
|
||||
}
|
||||
|
||||
setProjectPath(path: string | null): void {
|
||||
this.projectPath = path;
|
||||
}
|
||||
|
||||
isUsingDirectSource(): boolean {
|
||||
return localStorage.getItem(this.USE_DIRECT_SOURCE_KEY) === 'true';
|
||||
}
|
||||
|
||||
setUseDirectSource(useDirect: boolean): void {
|
||||
localStorage.setItem(this.USE_DIRECT_SOURCE_KEY, String(useDirect));
|
||||
}
|
||||
|
||||
async fetchPluginList(bypassCache: boolean = false): Promise<PluginMarketMetadata[]> {
|
||||
const useDirectSource = this.isUsingDirectSource();
|
||||
|
||||
if (useDirectSource) {
|
||||
return await this.fetchFromUrl(this.GITHUB_DIRECT_URL, bypassCache);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < this.REGISTRY_URLS.length; i++) {
|
||||
try {
|
||||
const url = this.REGISTRY_URLS[i];
|
||||
if (!url) continue;
|
||||
|
||||
const plugins = await this.fetchFromUrl(url, bypassCache, i + 1, this.REGISTRY_URLS.length);
|
||||
return plugins;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[PluginMarketService] Failed to fetch from URL ${i + 1}: ${errorMessage}`);
|
||||
errors.push(`URL ${i + 1}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
const finalError = `无法从任何数据源加载插件列表。尝试的错误:\n${errors.join('\n')}`;
|
||||
console.error('[PluginMarketService] All URLs failed:', finalError);
|
||||
throw new Error(finalError);
|
||||
}
|
||||
|
||||
private async fetchFromUrl(
|
||||
baseUrl: string,
|
||||
bypassCache: boolean,
|
||||
urlIndex?: number,
|
||||
totalUrls?: number
|
||||
): Promise<PluginMarketMetadata[]> {
|
||||
let url = baseUrl;
|
||||
if (bypassCache) {
|
||||
url += `?t=${Date.now()}`;
|
||||
if (urlIndex && totalUrls) {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const registry: PluginRegistry = await response.json();
|
||||
return registry.plugins;
|
||||
}
|
||||
|
||||
async installPlugin(plugin: PluginMarketMetadata, version?: string, onReload?: () => Promise<void>): Promise<void> {
|
||||
const targetVersion = version || plugin.latestVersion;
|
||||
if (!this.projectPath) {
|
||||
throw new Error('No project opened. Please open a project first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取指定版本信息
|
||||
const versionInfo = plugin.versions.find((v) => v.version === targetVersion);
|
||||
if (!versionInfo) {
|
||||
throw new Error(`Version ${targetVersion} not found for plugin ${plugin.name}`);
|
||||
}
|
||||
|
||||
// 下载 ZIP 文件
|
||||
const zipBlob = await this.downloadZip(versionInfo.zipUrl);
|
||||
|
||||
// 解压到项目 plugins 目录
|
||||
await this.extractZipToProject(zipBlob, plugin.id);
|
||||
|
||||
// 标记为已安装
|
||||
this.markAsInstalled(plugin, targetVersion);
|
||||
|
||||
// 重新加载项目插件
|
||||
if (onReload) {
|
||||
await onReload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PluginMarketService] Failed to install plugin ${plugin.name}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uninstallPlugin(pluginId: string, onReload?: () => Promise<void>): Promise<void> {
|
||||
if (!this.projectPath) {
|
||||
throw new Error('No project opened');
|
||||
}
|
||||
|
||||
try {
|
||||
// 从编辑器卸载
|
||||
await this.pluginManager.uninstallEditor(pluginId);
|
||||
|
||||
// 调用 Tauri 后端命令删除插件目录
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
await invoke('uninstall_marketplace_plugin', {
|
||||
projectPath: this.projectPath,
|
||||
pluginId: pluginId
|
||||
});
|
||||
|
||||
// 从已安装列表移除
|
||||
this.installedPlugins.delete(pluginId);
|
||||
this.saveInstalledPlugins();
|
||||
|
||||
// 重新加载项目插件
|
||||
if (onReload) {
|
||||
await onReload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PluginMarketService] Failed to uninstall plugin ${pluginId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
isInstalled(pluginId: string): boolean {
|
||||
return this.installedPlugins.has(pluginId);
|
||||
}
|
||||
|
||||
getInstalledVersion(pluginId: string): string | undefined {
|
||||
return this.installedPlugins.get(pluginId)?.version;
|
||||
}
|
||||
|
||||
hasUpdate(plugin: PluginMarketMetadata): boolean {
|
||||
const installedVersion = this.getInstalledVersion(plugin.id);
|
||||
if (!installedVersion) return false;
|
||||
|
||||
return this.compareVersions(plugin.latestVersion, installedVersion) > 0;
|
||||
}
|
||||
|
||||
private async downloadZip(url: string): Promise<Blob> {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ZIP: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
private async extractZipToProject(zipBlob: Blob, pluginId: string): Promise<void> {
|
||||
if (!this.projectPath) {
|
||||
throw new Error('Project path not set');
|
||||
}
|
||||
|
||||
try {
|
||||
// 将 Blob 转换为 ArrayBuffer
|
||||
const arrayBuffer = await zipBlob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
// 转换为 base64
|
||||
let binary = '';
|
||||
const len = uint8Array.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(uint8Array[i] ?? 0);
|
||||
}
|
||||
const base64Data = btoa(binary);
|
||||
|
||||
// 调用 Tauri 后端命令进行安装
|
||||
await invoke<string>('install_marketplace_plugin', {
|
||||
projectPath: this.projectPath,
|
||||
pluginId: pluginId,
|
||||
zipDataBase64: base64Data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to extract ZIP:', error);
|
||||
throw new Error(`Failed to extract plugin: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private markAsInstalled(plugin: PluginMarketMetadata, version: string): void {
|
||||
this.installedPlugins.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
version: version,
|
||||
installedAt: new Date().toISOString()
|
||||
});
|
||||
this.saveInstalledPlugins();
|
||||
}
|
||||
|
||||
private loadInstalledPlugins(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (stored) {
|
||||
const plugins: InstalledPluginInfo[] = JSON.parse(stored);
|
||||
this.installedPlugins = new Map(plugins.map((p) => [p.id, p]));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to load installed plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private saveInstalledPlugins(): void {
|
||||
try {
|
||||
const plugins = Array.from(this.installedPlugins.values());
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(plugins));
|
||||
} catch (error) {
|
||||
console.error('[PluginMarketService] Failed to save installed plugins:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 > part2) return 1;
|
||||
if (part1 < part2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,622 +0,0 @@
|
||||
import { GitHubService } from './GitHubService';
|
||||
import type { IEditorPluginMetadata } from '@esengine/editor-core';
|
||||
|
||||
export interface PluginPublishInfo {
|
||||
pluginMetadata: IEditorPluginMetadata;
|
||||
version: string;
|
||||
releaseNotes: string;
|
||||
repositoryUrl: string;
|
||||
category: 'official' | 'community';
|
||||
tags?: string[];
|
||||
homepage?: string;
|
||||
screenshots?: string[];
|
||||
requirements?: {
|
||||
'ecs-version': string;
|
||||
'editor-version'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type PublishStep =
|
||||
| 'checking-fork'
|
||||
| 'creating-fork'
|
||||
| 'checking-branch'
|
||||
| 'creating-branch'
|
||||
| 'creating-manifest'
|
||||
| 'uploading-files'
|
||||
| 'creating-pr'
|
||||
| 'complete';
|
||||
|
||||
export interface PublishProgress {
|
||||
step: PublishStep;
|
||||
message: string;
|
||||
progress: number; // 0-100
|
||||
}
|
||||
|
||||
export class PluginPublishService {
|
||||
private readonly REGISTRY_OWNER = 'esengine';
|
||||
private readonly REGISTRY_REPO = 'ecs-editor-plugins';
|
||||
|
||||
private githubService: GitHubService;
|
||||
private progressCallback?: (progress: PublishProgress) => void;
|
||||
|
||||
constructor(githubService: GitHubService) {
|
||||
this.githubService = githubService;
|
||||
}
|
||||
|
||||
setProgressCallback(callback: (progress: PublishProgress) => void): void {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
private notifyProgress(step: PublishStep, message: string, progress: number): void {
|
||||
this.progressCallback?.({ step, message, progress });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布插件到市场
|
||||
* @param publishInfo 插件发布信息
|
||||
* @param zipPath 插件 ZIP 文件路径(必需)
|
||||
* @returns Pull Request URL
|
||||
*/
|
||||
async publishPlugin(publishInfo: PluginPublishInfo, zipPath: string): Promise<string> {
|
||||
if (!this.githubService.isAuthenticated()) {
|
||||
throw new Error('Please login to GitHub first');
|
||||
}
|
||||
|
||||
try {
|
||||
const { branchName, existingPR } = await this.preparePublishEnvironment(
|
||||
publishInfo.pluginMetadata.name,
|
||||
publishInfo.version
|
||||
);
|
||||
|
||||
const user = this.githubService.getUser()!;
|
||||
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
|
||||
|
||||
// 上传 ZIP 文件
|
||||
await this.uploadZipFile(user.login, branchName, pluginId, publishInfo, zipPath);
|
||||
|
||||
// 生成并上传 manifest
|
||||
this.notifyProgress('creating-manifest', 'Generating manifest.json...', 60);
|
||||
const manifest = this.generateManifest(publishInfo, user.login);
|
||||
const manifestPath = `plugins/${publishInfo.category}/${pluginId}/manifest.json`;
|
||||
|
||||
await this.uploadManifest(user.login, branchName, manifestPath, manifest, publishInfo);
|
||||
|
||||
// 创建或更新 PR
|
||||
return await this.createOrUpdatePR(existingPR, branchName, publishInfo, user.login);
|
||||
} catch (error) {
|
||||
console.error('[PluginPublishService] Failed to publish plugin:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async preparePublishEnvironment(
|
||||
pluginName: string,
|
||||
version: string
|
||||
): Promise<{ branchName: string; existingPR: { number: number; html_url: string } | null }> {
|
||||
const user = this.githubService.getUser();
|
||||
if (!user) {
|
||||
throw new Error('User information not available');
|
||||
}
|
||||
|
||||
this.notifyProgress('checking-fork', 'Checking if fork exists...', 10);
|
||||
|
||||
try {
|
||||
await this.githubService.getRepository(user.login, this.REGISTRY_REPO);
|
||||
this.notifyProgress('checking-fork', 'Fork already exists', 15);
|
||||
} catch {
|
||||
this.notifyProgress('creating-fork', 'Creating fork...', 12);
|
||||
await this.githubService.forkRepository(this.REGISTRY_OWNER, this.REGISTRY_REPO);
|
||||
await this.sleep(3000);
|
||||
this.notifyProgress('creating-fork', 'Fork created successfully', 15);
|
||||
}
|
||||
|
||||
const branchName = `add-plugin-${pluginName}-v${version}`;
|
||||
this.notifyProgress('checking-branch', `Checking if branch '${branchName}' exists...`, 20);
|
||||
|
||||
let branchExists = false;
|
||||
let existingPR: { number: number; html_url: string } | null = null;
|
||||
|
||||
try {
|
||||
await this.githubService.getBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = true;
|
||||
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
existingPR = await this.githubService.findPullRequestByBranch(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
headBranch
|
||||
);
|
||||
|
||||
if (existingPR) {
|
||||
this.notifyProgress(
|
||||
'checking-branch',
|
||||
`Branch and PR already exist, will update existing PR #${existingPR.number}`,
|
||||
30
|
||||
);
|
||||
} else {
|
||||
this.notifyProgress('checking-branch', 'Branch exists, will reuse it', 30);
|
||||
}
|
||||
} catch {
|
||||
this.notifyProgress('checking-branch', 'Branch does not exist, will create new one', 25);
|
||||
}
|
||||
|
||||
if (!branchExists) {
|
||||
this.notifyProgress('creating-branch', `Creating branch '${branchName}'...`, 27);
|
||||
try {
|
||||
await this.githubService.createBranch(
|
||||
user.login,
|
||||
this.REGISTRY_REPO,
|
||||
branchName,
|
||||
'main'
|
||||
);
|
||||
this.notifyProgress('creating-branch', 'Branch created successfully', 30);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { branchName, existingPR };
|
||||
}
|
||||
|
||||
private generatePluginId(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
private async uploadZipFile(
|
||||
owner: string,
|
||||
branch: string,
|
||||
pluginId: string,
|
||||
publishInfo: PluginPublishInfo,
|
||||
zipPath: string
|
||||
): Promise<void> {
|
||||
const { TauriAPI } = await import('../api/tauri');
|
||||
const base64Zip = await TauriAPI.readFileAsBase64(zipPath);
|
||||
|
||||
this.notifyProgress('uploading-files', 'Uploading plugin ZIP file...', 30);
|
||||
|
||||
const zipFilePath = `plugins/${publishInfo.category}/${pluginId}/versions/${publishInfo.version}.zip`;
|
||||
|
||||
try {
|
||||
await this.githubService.createOrUpdateBinaryFile(
|
||||
owner,
|
||||
this.REGISTRY_REPO,
|
||||
zipFilePath,
|
||||
base64Zip,
|
||||
`Add ${publishInfo.pluginMetadata.displayName} v${publishInfo.version} ZIP`,
|
||||
branch
|
||||
);
|
||||
this.notifyProgress('uploading-files', 'ZIP file uploaded successfully', 55);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to upload ZIP: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getExistingManifest(
|
||||
pluginId: string,
|
||||
category: 'official' | 'community'
|
||||
): Promise<Record<string, any> | null> {
|
||||
try {
|
||||
const manifestPath = `plugins/${category}/${pluginId}/manifest.json`;
|
||||
const content = await this.githubService.getFileContent(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
manifestPath,
|
||||
'main'
|
||||
);
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadManifest(
|
||||
owner: string,
|
||||
branch: string,
|
||||
manifestPath: string,
|
||||
manifest: Record<string, unknown>,
|
||||
publishInfo: PluginPublishInfo
|
||||
): Promise<void> {
|
||||
this.notifyProgress('uploading-files', 'Checking for existing manifest...', 65);
|
||||
|
||||
const pluginId = this.generatePluginId(publishInfo.pluginMetadata.name);
|
||||
const existingManifest = await this.getExistingManifest(pluginId, publishInfo.category);
|
||||
|
||||
let finalManifest = manifest;
|
||||
|
||||
if (existingManifest) {
|
||||
this.notifyProgress('uploading-files', 'Merging with existing manifest...', 68);
|
||||
finalManifest = this.mergeManifestVersions(existingManifest, manifest, publishInfo.version);
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', `Uploading manifest to ${manifestPath}...`, 70);
|
||||
|
||||
try {
|
||||
await this.githubService.createOrUpdateFile(
|
||||
owner,
|
||||
this.REGISTRY_REPO,
|
||||
manifestPath,
|
||||
JSON.stringify(finalManifest, null, 2),
|
||||
`Add ${publishInfo.pluginMetadata.displayName} v${publishInfo.version}`,
|
||||
branch
|
||||
);
|
||||
this.notifyProgress('uploading-files', 'Manifest uploaded successfully', 80);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to upload manifest: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeManifestVersions(
|
||||
existingManifest: Record<string, any>,
|
||||
newManifest: Record<string, any>,
|
||||
newVersion: string
|
||||
): Record<string, any> {
|
||||
const existingVersions: any[] = Array.isArray(existingManifest.versions)
|
||||
? existingManifest.versions
|
||||
: [];
|
||||
|
||||
const newVersionInfo = (newManifest.versions as any[])[0];
|
||||
|
||||
const versionExists = existingVersions.some((v: any) => v.version === newVersion);
|
||||
|
||||
let updatedVersions: any[];
|
||||
if (versionExists) {
|
||||
updatedVersions = existingVersions.map((v: any) =>
|
||||
v.version === newVersion ? newVersionInfo : v
|
||||
);
|
||||
} else {
|
||||
updatedVersions = [...existingVersions, newVersionInfo];
|
||||
}
|
||||
|
||||
updatedVersions.sort((a: any, b: any) => {
|
||||
const [aMajor, aMinor, aPatch] = a.version.split('.').map(Number);
|
||||
const [bMajor, bMinor, bPatch] = b.version.split('.').map(Number);
|
||||
|
||||
if (aMajor !== bMajor) return bMajor - aMajor;
|
||||
if (aMinor !== bMinor) return bMinor - aMinor;
|
||||
return bPatch - aPatch;
|
||||
});
|
||||
|
||||
const mergedManifest: any = {
|
||||
...existingManifest,
|
||||
...newManifest,
|
||||
latestVersion: updatedVersions[0].version,
|
||||
versions: updatedVersions
|
||||
};
|
||||
|
||||
delete mergedManifest.version;
|
||||
delete mergedManifest.distribution;
|
||||
|
||||
return mergedManifest as Record<string, any>;
|
||||
}
|
||||
|
||||
private async createOrUpdatePR(
|
||||
existingPR: { number: number; html_url: string } | null,
|
||||
branchName: string,
|
||||
publishInfo: PluginPublishInfo,
|
||||
userLogin: string
|
||||
): Promise<string> {
|
||||
let prUrl: string;
|
||||
|
||||
if (existingPR) {
|
||||
prUrl = existingPR.html_url;
|
||||
this.notifyProgress('complete', `Pull request #${existingPR.number} updated successfully!`, 100);
|
||||
} else {
|
||||
this.notifyProgress('creating-pr', 'Creating pull request...', 85);
|
||||
|
||||
const prTitle = `Add plugin: ${publishInfo.pluginMetadata.displayName} v${publishInfo.version}`;
|
||||
const prBody = this.generatePRDescription(publishInfo);
|
||||
|
||||
try {
|
||||
prUrl = await this.githubService.createPullRequest({
|
||||
owner: this.REGISTRY_OWNER,
|
||||
repo: this.REGISTRY_REPO,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
head: `${userLogin}:${branchName}`,
|
||||
base: 'main'
|
||||
});
|
||||
|
||||
this.notifyProgress('complete', 'Pull request created successfully!', 100);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return prUrl;
|
||||
}
|
||||
|
||||
private generateManifest(publishInfo: PluginPublishInfo, githubUsername: string): Record<string, unknown> {
|
||||
const { pluginMetadata, version, releaseNotes, repositoryUrl, category, tags, homepage, screenshots, requirements } =
|
||||
publishInfo;
|
||||
|
||||
const repoMatch = repositoryUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (!repoMatch || !repoMatch[1] || !repoMatch[2]) {
|
||||
throw new Error('Invalid GitHub repository URL');
|
||||
}
|
||||
|
||||
const owner = repoMatch[1];
|
||||
const repo = repoMatch[2];
|
||||
const repoName = repo.replace(/\.git$/, '');
|
||||
|
||||
const pluginId = this.generatePluginId(pluginMetadata.name);
|
||||
|
||||
const zipUrl = `https://cdn.jsdelivr.net/gh/${this.REGISTRY_OWNER}/${this.REGISTRY_REPO}@gh-pages/plugins/${category}/${pluginId}/versions/${version}.zip`;
|
||||
|
||||
const categoryMap: Record<string, string> = {
|
||||
'editor': 'Window',
|
||||
'tool': 'Tool',
|
||||
'inspector': 'Inspector',
|
||||
'system': 'System',
|
||||
'import-export': 'ImportExport'
|
||||
};
|
||||
|
||||
const validCategory = categoryMap[pluginMetadata.category?.toLowerCase() || ''] || 'Tool';
|
||||
|
||||
const versionInfo = {
|
||||
version: version,
|
||||
releaseDate: new Date().toISOString(),
|
||||
changes: releaseNotes || 'No release notes provided',
|
||||
zipUrl: zipUrl,
|
||||
requirements: requirements || {
|
||||
'ecs-version': '>=1.0.0',
|
||||
'editor-version': '>=1.0.0'
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
id: pluginId,
|
||||
name: pluginMetadata.displayName,
|
||||
latestVersion: version,
|
||||
versions: [versionInfo],
|
||||
author: {
|
||||
name: githubUsername,
|
||||
github: githubUsername
|
||||
},
|
||||
description: pluginMetadata.description || 'No description provided',
|
||||
category: validCategory,
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: repositoryUrl
|
||||
},
|
||||
license: 'MIT',
|
||||
tags: tags || [],
|
||||
icon: pluginMetadata.icon || 'Package',
|
||||
homepage: homepage || repositoryUrl,
|
||||
screenshots: screenshots || []
|
||||
};
|
||||
}
|
||||
|
||||
private generatePRDescription(publishInfo: PluginPublishInfo): string {
|
||||
const { pluginMetadata, version, releaseNotes, repositoryUrl, category } = publishInfo;
|
||||
|
||||
return `## Plugin Submission
|
||||
|
||||
### Plugin Information
|
||||
|
||||
- **Name**: ${pluginMetadata.displayName}
|
||||
- **ID**: ${pluginMetadata.name}
|
||||
- **Version**: ${version}
|
||||
- **Category**: ${category}
|
||||
- **Repository**: ${repositoryUrl}
|
||||
|
||||
### Description
|
||||
|
||||
${pluginMetadata.description || 'No description provided'}
|
||||
|
||||
### Release Notes
|
||||
|
||||
${releaseNotes}
|
||||
|
||||
### Checklist
|
||||
|
||||
- [x] Plugin is built and tested
|
||||
- [x] Repository is publicly accessible
|
||||
- [x] Manifest.json is correctly formatted
|
||||
- [ ] Code has been reviewed for security concerns
|
||||
- [ ] Plugin follows ECS Editor plugin guidelines
|
||||
|
||||
---
|
||||
|
||||
**Submitted via ECS Editor Plugin Publisher**
|
||||
`;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async deletePlugin(pluginId: string, pluginName: string, category: 'official' | 'community', reason: string, forceRecreate: boolean = false): Promise<string> {
|
||||
if (!this.githubService.isAuthenticated()) {
|
||||
throw new Error('Please login to GitHub first');
|
||||
}
|
||||
|
||||
const user = this.githubService.getUser();
|
||||
if (!user) {
|
||||
throw new Error('User information not available');
|
||||
}
|
||||
|
||||
this.notifyProgress('checking-fork', 'Checking if fork exists...', 5);
|
||||
|
||||
try {
|
||||
let forkedRepo: string;
|
||||
|
||||
try {
|
||||
await this.githubService.getRepository(user.login, this.REGISTRY_REPO);
|
||||
forkedRepo = `${user.login}/${this.REGISTRY_REPO}`;
|
||||
this.notifyProgress('checking-fork', 'Fork already exists', 10);
|
||||
} catch {
|
||||
this.notifyProgress('creating-fork', 'Creating fork...', 7);
|
||||
forkedRepo = await this.githubService.forkRepository(this.REGISTRY_OWNER, this.REGISTRY_REPO);
|
||||
await this.sleep(3000);
|
||||
this.notifyProgress('creating-fork', 'Fork created successfully', 10);
|
||||
}
|
||||
|
||||
const branchName = `remove-plugin-${pluginId}`;
|
||||
this.notifyProgress('checking-branch', `Checking if branch '${branchName}' exists...`, 15);
|
||||
|
||||
let branchExists = false;
|
||||
let existingPR: { number: number; html_url: string } | null = null;
|
||||
|
||||
try {
|
||||
await this.githubService.getBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = true;
|
||||
|
||||
if (forceRecreate) {
|
||||
this.notifyProgress('checking-branch', 'Deleting old branch to recreate...', 16);
|
||||
await this.githubService.deleteBranch(user.login, this.REGISTRY_REPO, branchName);
|
||||
branchExists = false;
|
||||
this.notifyProgress('checking-branch', 'Old branch deleted', 17);
|
||||
} else {
|
||||
const headBranch = `${user.login}:${branchName}`;
|
||||
existingPR = await this.githubService.findPullRequestByBranch(this.REGISTRY_OWNER, this.REGISTRY_REPO, headBranch);
|
||||
|
||||
if (existingPR) {
|
||||
this.notifyProgress('checking-branch', `Branch and PR already exist, will update existing PR #${existingPR.number}`, 20);
|
||||
} else {
|
||||
this.notifyProgress('checking-branch', 'Branch exists, will reuse it', 20);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.notifyProgress('checking-branch', 'Branch does not exist, will create new one', 18);
|
||||
}
|
||||
|
||||
if (!branchExists) {
|
||||
this.notifyProgress('creating-branch', `Creating branch '${branchName}' from main repository...`, 19);
|
||||
|
||||
try {
|
||||
const mainRef = await this.githubService.getRef(this.REGISTRY_OWNER, this.REGISTRY_REPO, 'heads/main');
|
||||
await this.githubService.createBranchFromSha(user.login, this.REGISTRY_REPO, branchName, mainRef.object.sha);
|
||||
this.notifyProgress('creating-branch', 'Branch created successfully', 20);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', 'Collecting plugin files...', 25);
|
||||
|
||||
const pluginPath = `plugins/${category}/${pluginId}`;
|
||||
|
||||
const contents = await this.githubService.getDirectoryContents(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
pluginPath,
|
||||
'main'
|
||||
);
|
||||
|
||||
if (contents.length === 0) {
|
||||
throw new Error(`Plugin directory not found: ${pluginPath}`);
|
||||
}
|
||||
|
||||
const filesToDelete: Array<{ path: string; sha: string }> = [];
|
||||
|
||||
for (const item of contents) {
|
||||
if (item.type === 'file') {
|
||||
filesToDelete.push({ path: item.path, sha: item.sha });
|
||||
} else if (item.type === 'dir') {
|
||||
const subContents = await this.githubService.getDirectoryContents(
|
||||
this.REGISTRY_OWNER,
|
||||
this.REGISTRY_REPO,
|
||||
item.path,
|
||||
'main'
|
||||
);
|
||||
for (const subItem of subContents) {
|
||||
if (subItem.type === 'file') {
|
||||
filesToDelete.push({ path: subItem.path, sha: subItem.sha });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToDelete.length === 0) {
|
||||
throw new Error(`No files found to delete in ${pluginPath}`);
|
||||
}
|
||||
|
||||
this.notifyProgress('uploading-files', `Deleting ${filesToDelete.length} files...`, 40);
|
||||
|
||||
let deletedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await this.githubService.deleteFileWithSha(
|
||||
user.login,
|
||||
this.REGISTRY_REPO,
|
||||
file.path,
|
||||
file.sha,
|
||||
`Remove ${pluginName}`,
|
||||
branchName
|
||||
);
|
||||
deletedCount++;
|
||||
const progress = 40 + Math.floor((deletedCount / filesToDelete.length) * 40);
|
||||
this.notifyProgress('uploading-files', `Deleted ${deletedCount}/${filesToDelete.length} files`, progress);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[PluginPublishService] Failed to delete ${file.path}:`, errorMsg);
|
||||
errors.push(`${file.path}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Failed to delete ${errors.length} file(s):\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
if (deletedCount === 0) {
|
||||
throw new Error('No files were deleted');
|
||||
}
|
||||
|
||||
let prUrl: string;
|
||||
|
||||
if (existingPR) {
|
||||
prUrl = existingPR.html_url;
|
||||
this.notifyProgress('complete', `Pull request #${existingPR.number} updated successfully!`, 100);
|
||||
} else {
|
||||
this.notifyProgress('creating-pr', 'Creating pull request...', 85);
|
||||
|
||||
const prTitle = `Remove plugin: ${pluginName}`;
|
||||
const prBody = `## Plugin Removal Request
|
||||
|
||||
### Plugin Information
|
||||
|
||||
- **Name**: ${pluginName}
|
||||
- **ID**: ${pluginId}
|
||||
- **Category**: ${category}
|
||||
|
||||
### Reason for Removal
|
||||
|
||||
${reason}
|
||||
|
||||
---
|
||||
|
||||
**Submitted via ECS Editor Plugin Manager**
|
||||
`;
|
||||
|
||||
try {
|
||||
prUrl = await this.githubService.createPullRequest({
|
||||
owner: this.REGISTRY_OWNER,
|
||||
repo: this.REGISTRY_REPO,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
head: `${user.login}:${branchName}`,
|
||||
base: 'main'
|
||||
});
|
||||
|
||||
this.notifyProgress('complete', 'Pull request created successfully!', 100);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return prUrl;
|
||||
} catch (error) {
|
||||
console.error('[PluginPublishService] Failed to delete plugin:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
159
packages/editor-app/src/services/PluginSDKRegistry.ts
Normal file
159
packages/editor-app/src/services/PluginSDKRegistry.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Plugin SDK Registry
|
||||
* 插件 SDK 注册器
|
||||
*
|
||||
* 将编辑器核心模块暴露为全局变量,供插件使用。
|
||||
* 插件构建时将这些模块标记为 external,运行时从全局对象获取。
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 编辑器启动时调用 PluginSDKRegistry.initialize()
|
||||
* 2. 插件构建配置中设置 external: ['@esengine/editor-runtime', ...]
|
||||
* 3. 插件构建配置中设置 globals: { '@esengine/editor-runtime': '__ESENGINE__.editorRuntime' }
|
||||
*/
|
||||
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||
|
||||
// 导入所有需要暴露给插件的模块
|
||||
import * as editorRuntime from '@esengine/editor-runtime';
|
||||
import * as ecsFramework from '@esengine/ecs-framework';
|
||||
import * as behaviorTree from '@esengine/behavior-tree';
|
||||
import * as ecsComponents from '@esengine/ecs-components';
|
||||
|
||||
// 存储服务实例引用(在初始化时设置)
|
||||
let entityStoreInstance: EntityStoreService | null = null;
|
||||
let messageHubInstance: MessageHub | null = null;
|
||||
|
||||
// SDK 模块映射
|
||||
const SDK_MODULES = {
|
||||
'@esengine/editor-runtime': editorRuntime,
|
||||
'@esengine/ecs-framework': ecsFramework,
|
||||
'@esengine/behavior-tree': behaviorTree,
|
||||
'@esengine/ecs-components': ecsComponents,
|
||||
} as const;
|
||||
|
||||
// 全局变量名称映射(用于插件构建配置)
|
||||
export const SDK_GLOBALS = {
|
||||
'@esengine/editor-runtime': '__ESENGINE__.editorRuntime',
|
||||
'@esengine/ecs-framework': '__ESENGINE__.ecsFramework',
|
||||
'@esengine/behavior-tree': '__ESENGINE__.behaviorTree',
|
||||
'@esengine/ecs-components': '__ESENGINE__.ecsComponents',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 插件 API 接口
|
||||
* 为插件提供统一的访问接口,避免模块实例不一致的问题
|
||||
*/
|
||||
export interface IPluginAPI {
|
||||
/** 获取当前场景 */
|
||||
getScene(): any;
|
||||
/** 获取 EntityStoreService */
|
||||
getEntityStore(): EntityStoreService;
|
||||
/** 获取 MessageHub */
|
||||
getMessageHub(): MessageHub;
|
||||
/** 解析服务 */
|
||||
resolveService<T>(serviceType: any): T;
|
||||
/** 获取 Core 实例 */
|
||||
getCore(): typeof Core;
|
||||
}
|
||||
|
||||
// 扩展 Window.__ESENGINE__ 类型(基础类型已在 PluginAPI.ts 中定义)
|
||||
interface ESEngineGlobal {
|
||||
editorRuntime: typeof editorRuntime;
|
||||
ecsFramework: typeof ecsFramework;
|
||||
behaviorTree: typeof behaviorTree;
|
||||
ecsComponents: typeof ecsComponents;
|
||||
require: (moduleName: string) => any;
|
||||
api: IPluginAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件 SDK 注册器
|
||||
*/
|
||||
export class PluginSDKRegistry {
|
||||
private static initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化 SDK 注册器
|
||||
* 将所有 SDK 模块暴露到全局对象
|
||||
*/
|
||||
static initialize(): void {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取服务实例(使用编辑器内部的类型,确保类型匹配)
|
||||
entityStoreInstance = Core.services.resolve(EntityStoreService);
|
||||
messageHubInstance = Core.services.resolve(MessageHub);
|
||||
|
||||
if (!entityStoreInstance) {
|
||||
console.error('[PluginSDKRegistry] EntityStoreService not registered yet!');
|
||||
}
|
||||
if (!messageHubInstance) {
|
||||
console.error('[PluginSDKRegistry] MessageHub not registered yet!');
|
||||
}
|
||||
|
||||
// 创建插件 API - 直接返回实例引用,避免类型匹配问题
|
||||
const pluginAPI: IPluginAPI = {
|
||||
getScene: () => Core.scene,
|
||||
getEntityStore: () => {
|
||||
if (!entityStoreInstance) {
|
||||
throw new Error('[PluginAPI] EntityStoreService not initialized');
|
||||
}
|
||||
return entityStoreInstance;
|
||||
},
|
||||
getMessageHub: () => {
|
||||
if (!messageHubInstance) {
|
||||
throw new Error('[PluginAPI] MessageHub not initialized');
|
||||
}
|
||||
return messageHubInstance;
|
||||
},
|
||||
resolveService: <T>(serviceType: any): T => Core.services.resolve(serviceType) as T,
|
||||
getCore: () => Core,
|
||||
};
|
||||
|
||||
// 创建全局命名空间
|
||||
window.__ESENGINE__ = {
|
||||
editorRuntime,
|
||||
ecsFramework,
|
||||
behaviorTree,
|
||||
ecsComponents,
|
||||
require: this.requireModule.bind(this),
|
||||
api: pluginAPI,
|
||||
};
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态获取模块(用于 CommonJS 风格的插件)
|
||||
*/
|
||||
private static requireModule(moduleName: string): any {
|
||||
const module = SDK_MODULES[moduleName as keyof typeof SDK_MODULES];
|
||||
if (!module) {
|
||||
throw new Error(`[PluginSDKRegistry] Unknown module: ${moduleName}`);
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
*/
|
||||
static isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的 SDK 模块名称
|
||||
*/
|
||||
static getAvailableModules(): string[] {
|
||||
return Object.keys(SDK_MODULES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局变量映射(用于生成插件构建配置)
|
||||
*/
|
||||
static getGlobalsConfig(): Record<string, string> {
|
||||
return { ...SDK_GLOBALS };
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,11 @@ export class ProfilerService {
|
||||
await invoke<string>('start_profiler_server', { port });
|
||||
this.isServerRunning = true;
|
||||
} catch (error) {
|
||||
console.error('[ProfilerService] Failed to start server:', error);
|
||||
// Ignore "already running" error - it's expected in some cases
|
||||
const errorMessage = String(error);
|
||||
if (!errorMessage.includes('already running')) {
|
||||
console.error('[ProfilerService] Failed to start server:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*
|
||||
* Resolves runtime module paths based on environment and configuration
|
||||
* 根据环境和配置解析运行时模块路径
|
||||
*
|
||||
* 运行时文件打包在编辑器内,离线可用
|
||||
*/
|
||||
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
@@ -14,14 +16,18 @@ const sanitizePath = (path: string): string => {
|
||||
const segments = path.split(/[/\\]/).filter((segment) =>
|
||||
segment !== '..' && segment !== '.' && segment !== ''
|
||||
);
|
||||
return segments.join('/');
|
||||
// Use Windows backslash for consistency
|
||||
return segments.join('\\');
|
||||
};
|
||||
|
||||
// Check if we're in development mode
|
||||
const isDevelopment = (): boolean => {
|
||||
try {
|
||||
// Vite environment variable
|
||||
return (import.meta as any).env?.DEV === true;
|
||||
// Vite environment variable - this is the most reliable check
|
||||
const viteDev = (import.meta as any).env?.DEV === true;
|
||||
// Also check if MODE is 'development'
|
||||
const viteMode = (import.meta as any).env?.MODE === 'development';
|
||||
return viteDev || viteMode;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -44,6 +50,7 @@ export class RuntimeResolver {
|
||||
private static instance: RuntimeResolver;
|
||||
private config: RuntimeConfig | null = null;
|
||||
private baseDir: string = '';
|
||||
private isDev: boolean = false; // Store dev mode state at initialization time
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -70,19 +77,35 @@ export class RuntimeResolver {
|
||||
}
|
||||
this.config = await response.json();
|
||||
|
||||
// Determine base directory based on environment
|
||||
if (isDevelopment()) {
|
||||
// In development, use the project root
|
||||
// We need to go up from src-tauri to get the actual project root
|
||||
const currentDir = await TauriAPI.getCurrentDir();
|
||||
// currentDir might be src-tauri, so we need to find the actual workspace root
|
||||
this.baseDir = await this.findWorkspaceRoot(currentDir);
|
||||
// 查找 workspace 根目录
|
||||
const currentDir = await TauriAPI.getCurrentDir();
|
||||
const workspaceRoot = await this.findWorkspaceRoot(currentDir);
|
||||
|
||||
// 优先使用 workspace 中的开发文件(如果存在)
|
||||
// Prefer workspace dev files if they exist
|
||||
if (await this.hasRuntimeFilesInWorkspace(workspaceRoot)) {
|
||||
this.baseDir = workspaceRoot;
|
||||
this.isDev = true;
|
||||
console.log(`[RuntimeResolver] Using workspace dev files: ${this.baseDir}`);
|
||||
} else {
|
||||
// In production, use the resource directory
|
||||
// 回退到打包的资源目录(生产模式)
|
||||
this.baseDir = await TauriAPI.getAppResourceDir();
|
||||
this.isDev = false;
|
||||
console.log(`[RuntimeResolver] Using bundled resource dir: ${this.baseDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime files exist in workspace
|
||||
* 检查 workspace 中是否存在运行时文件
|
||||
*/
|
||||
private async hasRuntimeFilesInWorkspace(workspaceRoot: string): Promise<boolean> {
|
||||
const runtimePath = `${workspaceRoot}\\packages\\platform-web\\dist\\runtime.browser.js`;
|
||||
const exists = await TauriAPI.pathExists(runtimePath);
|
||||
console.log(`[RuntimeResolver] Checking workspace runtime: ${runtimePath} -> ${exists}`);
|
||||
return exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find workspace root by looking for package.json or specific markers
|
||||
* 通过查找 package.json 或特定标记来找到工作区根目录
|
||||
@@ -140,14 +163,14 @@ export class RuntimeResolver {
|
||||
throw new Error(`Runtime module ${moduleName} not found in configuration`);
|
||||
}
|
||||
|
||||
const isDev = isDevelopment();
|
||||
const files: string[] = [];
|
||||
let sourcePath: string;
|
||||
|
||||
if (isDev) {
|
||||
if (this.isDev) {
|
||||
// Development mode - use relative paths from workspace root
|
||||
const devPath = moduleConfig.development.path;
|
||||
sourcePath = `${this.baseDir}\\packages\\${sanitizePath(devPath)}`;
|
||||
const sanitizedPath = sanitizePath(devPath);
|
||||
sourcePath = `${this.baseDir}\\packages\\${sanitizedPath}`;
|
||||
|
||||
if (moduleConfig.main) {
|
||||
files.push(`${sourcePath}\\${moduleConfig.main}`);
|
||||
@@ -181,8 +204,14 @@ export class RuntimeResolver {
|
||||
/**
|
||||
* Prepare runtime files for browser preview
|
||||
* 为浏览器预览准备运行时文件
|
||||
*
|
||||
* 开发模式:从本地 workspace 复制
|
||||
* 生产模式:从编辑器内置资源复制
|
||||
*/
|
||||
async prepareRuntimeFiles(targetDir: string): Promise<void> {
|
||||
console.log(`[RuntimeResolver] Preparing runtime files to: ${targetDir}`);
|
||||
console.log(`[RuntimeResolver] isDev: ${this.isDev}, baseDir: ${this.baseDir}`);
|
||||
|
||||
// Ensure target directory exists
|
||||
const dirExists = await TauriAPI.pathExists(targetDir);
|
||||
if (!dirExists) {
|
||||
@@ -191,12 +220,16 @@ export class RuntimeResolver {
|
||||
|
||||
// Copy platform-web runtime
|
||||
const platformWeb = await this.getModuleFiles('platform-web');
|
||||
console.log(`[RuntimeResolver] platform-web files:`, platformWeb.files);
|
||||
for (const srcFile of platformWeb.files) {
|
||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
||||
const dstFile = `${targetDir}\\${filename}`;
|
||||
|
||||
if (await TauriAPI.pathExists(srcFile)) {
|
||||
const srcExists = await TauriAPI.pathExists(srcFile);
|
||||
console.log(`[RuntimeResolver] Copying ${srcFile} -> ${dstFile} (src exists: ${srcExists})`);
|
||||
if (srcExists) {
|
||||
await TauriAPI.copyFile(srcFile, dstFile);
|
||||
console.log(`[RuntimeResolver] Copied ${filename}`);
|
||||
} else {
|
||||
throw new Error(`Runtime file not found: ${srcFile}`);
|
||||
}
|
||||
@@ -204,16 +237,22 @@ export class RuntimeResolver {
|
||||
|
||||
// Copy engine WASM files
|
||||
const engine = await this.getModuleFiles('engine');
|
||||
console.log(`[RuntimeResolver] engine files:`, engine.files);
|
||||
for (const srcFile of engine.files) {
|
||||
const filename = srcFile.split(/[/\\]/).pop() || '';
|
||||
const dstFile = `${targetDir}\\${filename}`;
|
||||
|
||||
if (await TauriAPI.pathExists(srcFile)) {
|
||||
const srcExists = await TauriAPI.pathExists(srcFile);
|
||||
console.log(`[RuntimeResolver] Copying ${srcFile} -> ${dstFile} (src exists: ${srcExists})`);
|
||||
if (srcExists) {
|
||||
await TauriAPI.copyFile(srcFile, dstFile);
|
||||
console.log(`[RuntimeResolver] Copied ${filename}`);
|
||||
} else {
|
||||
throw new Error(`Engine file not found: ${srcFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[RuntimeResolver] Runtime files prepared successfully`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user