Skip to content

Commit 6617dcf

Browse files
committed
✨ feat: load default plugins on startup
1 parent 645ef68 commit 6617dcf

File tree

19 files changed

+270
-144
lines changed

19 files changed

+270
-144
lines changed

packages/chili-builder/src/appBuilder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export class AppBuilder {
119119
this.ensureNecessary();
120120

121121
const app = this.createApp();
122+
await app.init();
122123
await this._window?.init(app);
123124

124125
this.loadAdditionalCommands();

packages/chili-controls/src/controls.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ export function createIcon(icon: CommandIcon): Element {
2121
}
2222
case "url":
2323
return img({ src: icon.value });
24-
case "plugin":
25-
console.trace("Plugin icon is not supported");
24+
case "path":
2625
throw new Error("Plugin icon is not supported, please transform it to other icon type");
2726
default:
2827
return svg({ icon: "icon-chili" });

packages/chili-core/src/command/commandData.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ export type IconPng = { type: "png"; value: Uint8Array };
1010

1111
export type IconUrl = { type: "url"; value: string };
1212

13-
/**
14-
* Only for plugin use, should transform to other type
15-
*/
16-
export type IconPath = { type: "plugin"; path: string };
13+
export type IconPath = { type: "path"; value: string };
1714

1815
export type CommandIcon = string | IconSvg | IconPng | IconUrl | IconPath;
1916

packages/chili-core/src/plugin/manager.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,12 @@ export interface IPluginManager {
1212
*/
1313
loadFromFile(pluginFile: File): Promise<void>;
1414
/**
15-
* Load plugins from a url, the plugin file must be a zip file
15+
* Load plugins from a url,
16+
* if the url is a zip file, it will be loaded as a plugin, eg: https://example.com/plugin1.chiliplugin
17+
* if the url is a directory, it will be loaded as a plugin folder, eg: https://example.com/plugin1, the plugin folder must have a manifest.json file inside,
1618
* @param pluginUrl the plugin url
1719
*/
1820
loadFromUrl(pluginUrl: string): Promise<void>;
19-
/**
20-
* Load plugins from a folder, the index file is optional, if not specified, the default plugins.json will be used
21-
* @param folderUrl the folder url
22-
* @param indexName the index file name, default is plugins.json
23-
*/
24-
loadFromFolder(folderUrl: string, indexName?: string): Promise<void>;
2521
unload(pluginName: string): Promise<void>;
2622
unloadAll(): void;
2723
getPlugins(): Plugin[];

packages/chili/src/application.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,13 @@ export class Application implements IApplication {
7474
this.dataExchange = option.dataExchange;
7575
this.mainWindow = option.mainWindow;
7676
this.pluginManager = new PluginManager(this);
77+
}
7778

79+
async init() {
7880
this.services.forEach((x) => x.register(this));
7981
this.services.forEach((x) => x.start());
80-
8182
this.initWindowEvents();
82-
this.loadPluginsFromPublic();
83-
}
84-
85-
private async loadPluginsFromPublic() {
86-
await this.pluginManager.loadFromFolder("./plugins/");
83+
await this.loadDefaultPlugins("plugins/");
8784
}
8885

8986
private initWindowEvents() {
@@ -237,4 +234,21 @@ export class Application implements IApplication {
237234
const view = document.visual.createView("3d", Plane.XY);
238235
this.activeView = view;
239236
}
237+
238+
protected async loadDefaultPlugins(folderUrl: string) {
239+
try {
240+
const response = await fetch(folderUrl + "plugins.json");
241+
if (!response.ok) {
242+
return;
243+
}
244+
const config = await response.json();
245+
const plugins = config.plugins as string[];
246+
const baseUrl = folderUrl.endsWith("/") ? folderUrl : folderUrl + "/";
247+
for (const plugin of plugins ?? []) {
248+
await this.pluginManager.loadFromUrl(baseUrl + plugin);
249+
}
250+
} catch {
251+
Logger.warn(`Failed to load plugins from folder: ${folderUrl}`);
252+
}
253+
}
240254
}

packages/chili/src/pluginManager.ts

Lines changed: 107 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
Logger,
1111
type Plugin,
1212
type PluginManifest,
13-
Result,
1413
toBase64Img,
1514
} from "chili-api";
1615
import type JSZip from "jszip";
@@ -25,101 +24,135 @@ export class PluginManager implements IPluginManager {
2524
const JSZip = await import("jszip");
2625
const zip = await JSZip.default.loadAsync(file);
2726

28-
const manifest = await this.readManifest(zip);
29-
if (!manifest) {
30-
return;
31-
}
32-
33-
if (await this.loadPluginCode(zip, manifest)) {
34-
Logger.info(`Plugin ${manifest.name} loaded successfully`);
27+
const manifest = await this.readManifestFromZip(zip);
28+
if (manifest) {
29+
await this.loadPluginCodeFromZip(zip, manifest);
3530
}
3631
}
3732

3833
async loadFromUrl(url: string) {
39-
const response = await fetch(url);
40-
if (!response.ok) {
41-
alert(`Failed to fetch plugin from ${url}: ${response.statusText}`);
42-
return;
43-
}
44-
45-
const arrayBuffer = await response.arrayBuffer();
46-
const blob = new Blob([arrayBuffer], { type: "application/zip" });
47-
const file = new File([blob], "plugin.chiliplugin");
48-
49-
await this.loadFromFile(file);
50-
}
51-
52-
async loadFromFolder(folderUrl: string, indexName: string = "plugins.json") {
53-
try {
54-
const response = await fetch(folderUrl + indexName);
34+
if (url.endsWith(".chiliplugin")) {
35+
const response = await fetch(url);
5536
if (!response.ok) {
37+
alert(`Failed to fetch plugin from ${url}: ${response.statusText}`);
5638
return;
5739
}
58-
const config = await response.json();
59-
const plugins = config.plugins as string[];
60-
const baseUrl = folderUrl.endsWith("/") ? folderUrl : folderUrl + "/";
61-
for (const plugin of plugins ?? []) {
62-
await this.loadFromUrl(baseUrl + plugin);
40+
41+
const arrayBuffer = await response.arrayBuffer();
42+
const blob = new Blob([arrayBuffer], { type: "application/zip" });
43+
const file = new File([blob], "plugin.chiliplugin");
44+
45+
await this.loadFromFile(file);
46+
} else {
47+
if (!url.endsWith("/")) url += "/";
48+
const manifest = await this.readManifestFromUrl(url + "manifest.json");
49+
if (manifest) {
50+
await this.loadPluginCodeFromUrl(manifest.name, url, manifest.main);
6351
}
64-
} catch {
65-
Logger.warn(`Failed to load plugins from folder: ${folderUrl}`);
6652
}
6753
}
6854

69-
private async readManifest(zip: JSZip) {
55+
private async readManifestFromZip(zip: JSZip) {
7056
const manifestFile = zip.file("manifest.json");
7157
if (!manifestFile) {
7258
alert("manifest.json not found in plugin archive");
7359
return undefined;
7460
}
61+
7562
const content = await manifestFile.async("text");
7663
const manifest = JSON.parse(content) as PluginManifest;
77-
const validation = this.validateManifest(manifest);
78-
if (!validation.isOk) {
79-
alert(validation.error);
64+
return this.validateManifest(manifest) ? manifest : undefined;
65+
}
66+
67+
private async readManifestFromUrl(url: string) {
68+
const response = await fetch(url);
69+
if (!response.ok) {
8070
return undefined;
8171
}
82-
83-
return manifest;
72+
const manifest: PluginManifest = await response.json();
73+
return this.validateManifest(manifest) ? manifest : undefined;
8474
}
8575

86-
private async loadPluginCode(zip: JSZip, manifest: PluginManifest) {
76+
private async loadPluginCodeFromZip(zip: JSZip, manifest: PluginManifest) {
8777
const codeFile = zip.file(manifest.main);
8878
if (!codeFile) {
8979
alert(manifest.main + " not found in plugin archive");
90-
return false;
80+
return;
9181
}
9282
const code = await codeFile.async("text");
83+
const handlePluginIcon = async (plugin: Plugin) => {
84+
await this.transformZipCommandIcon(zip, plugin);
85+
};
86+
await this.loadPluginCode(manifest.name, code, handlePluginIcon);
87+
}
88+
89+
private async loadPluginCodeFromUrl(name: string, baseUrl: string, codePath: string) {
90+
if (codePath.startsWith("/")) codePath = codePath.substring(1);
91+
92+
const fullUrl = baseUrl + codePath;
93+
const response = await fetch(fullUrl);
94+
if (!response.ok) {
95+
return undefined;
96+
}
97+
98+
const code = await response.text();
99+
const handlePluginIcon = async (plugin: Plugin) => {
100+
await this.transformUrlCommandIcon(baseUrl, plugin);
101+
};
102+
await this.loadPluginCode(name, code, handlePluginIcon);
103+
}
104+
105+
private async loadPluginCode(
106+
name: string,
107+
code: string,
108+
handlePluginIcon: (plugin: Plugin) => Promise<void>,
109+
) {
93110
const blob = new Blob([code], { type: "application/javascript" });
94111
const blobUrl = URL.createObjectURL(blob);
95112
await Promise.try(async () => {
96113
const module = await import(/*webpackIgnore: true*/ blobUrl);
97-
await this.transformCommandIcon(zip, module.default);
98-
this.registerPlugin(module.default);
99-
this.plugins.set(manifest.name, module.default);
100-
}).finally(() => {
101-
URL.revokeObjectURL(blobUrl);
102-
});
103-
return true;
114+
const plugin: Plugin = module.default;
115+
await handlePluginIcon(plugin);
116+
this.registerPlugin(plugin);
117+
this.plugins.set(name, plugin);
118+
119+
Logger.info(`Plugin ${name} loaded successfully`);
120+
})
121+
.catch((err) => {
122+
alert(`Failed to load plugin ${name}: ${err}`);
123+
})
124+
.finally(() => {
125+
URL.revokeObjectURL(blobUrl);
126+
});
104127
}
105128

106-
private async transformCommandIcon(zip: JSZip, plugin: Plugin) {
129+
private async transformZipCommandIcon(zip: JSZip, plugin: Plugin) {
107130
for (const command of plugin.commands ?? []) {
108131
const data = CommandStore.getComandData(command);
109132
const iconData = data?.icon as IconPath;
110-
if (iconData?.type === "plugin") {
111-
const codeFile = zip.file(iconData?.path);
133+
if (iconData?.type === "path") {
134+
const codeFile = zip.file(iconData?.value);
112135
if (!codeFile) {
113-
alert(`${iconData.path} not found in plugin archive`);
136+
alert(`${iconData.value} not found in plugin archive`);
114137
continue;
115138
}
116139
const icon = await codeFile.async("base64");
117-
const base64: string = toBase64Img(iconData.path, icon);
140+
const base64: string = toBase64Img(iconData.value, icon);
118141
data!.icon = { type: "url", value: base64 };
119142
}
120143
}
121144
}
122145

146+
private async transformUrlCommandIcon(baseUrl: string, plugin: Plugin) {
147+
for (const command of plugin.commands ?? []) {
148+
const data = CommandStore.getComandData(command);
149+
const iconData = data?.icon as IconPath;
150+
if (iconData?.type === "path") {
151+
data!.icon = { type: "url", value: baseUrl + iconData.value };
152+
}
153+
}
154+
}
155+
123156
async unload(pluginName: string): Promise<void> {
124157
const plugin = this.plugins.get(pluginName);
125158
if (!plugin) {
@@ -152,23 +185,38 @@ export class PluginManager implements IPluginManager {
152185
return this.plugins.has(pluginName);
153186
}
154187

155-
private validateManifest(manifest: PluginManifest): Result<boolean> {
156-
if (!manifest.name) Result.err("Missing required field: name");
157-
if (!manifest.version) Result.err("Missing required field: version");
158-
if (!manifest.main) Result.err("Missing required field: main");
188+
private validateManifest(manifest: PluginManifest) {
189+
if (this.manifests.has(manifest.name)) {
190+
alert(`Plugin ${manifest.name} already loaded`);
191+
return false;
192+
}
159193

194+
const errors: string[] = [];
195+
if (!manifest.name) errors.push("Missing required field: name");
196+
if (!manifest.version) errors.push("Missing required field: version");
197+
if (!manifest.main) errors.push("Missing required field: main");
160198
if (manifest.version && !this.isValidSemver(manifest.version)) {
161-
Result.err("Invalid version format (expected semver like 1.0.0)");
199+
errors.push("Invalid version format (expected semver like 1.0.0)");
162200
}
163-
164201
if (manifest.engines?.chili3d) {
165202
const currentVersion = __APP_VERSION__;
166203
if (!this.satisfiesVersion(currentVersion, manifest.engines.chili3d)) {
167-
Result.err(`Chili3D version ${currentVersion} does not satisfy ${manifest.engines.chili3d}`);
204+
errors.push(`Chili3D version ${currentVersion} does not satisfy ${manifest.engines.chili3d}`);
168205
}
169206
}
170207

171-
return Result.ok(true);
208+
if (errors.length > 0) {
209+
alert(
210+
"Load plugin " +
211+
manifest.name +
212+
" failed:\n" +
213+
errors.map((x, i) => `${i + 1}. ${x}`).join("\n"),
214+
);
215+
return false;
216+
}
217+
218+
this.manifests.set(manifest.name, manifest);
219+
return true;
172220
}
173221

174222
private isValidSemver(version: string): boolean {

plugins/helloworld-js/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
"name": "Hello World JS",
33
"version": "1.0.0",
44
"description": "A demo plugin for Chili3D showing plugin system capabilities",
5-
"main": "extension.js",
5+
"main": "src/extension.js",
66
"author": {
77
"name": "Chili3D Team"
88
},
99
"engines": {
10-
"chili3d": ">=0.7"
10+
"chili3d": ">=0.6"
1111
}
1212
}

plugins/helloworld-js/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"main": "extension.js",
66
"type": "module",
77
"scripts": {
8-
"package": "node ../scripts/package-plugin.mjs --entry src/extension.js --output dist/helloworld-js.chiliplugin"
8+
"package": "node ../scripts/package-plugin.mjs dist/helloworld-js.chiliplugin src/extension.js manifest.json icons"
99
},
1010
"keywords": [
1111
"chili3d",

0 commit comments

Comments
 (0)