521 lines
14 KiB
JavaScript
521 lines
14 KiB
JavaScript
/* global require, getApp, wx, console, module */
|
|
|
|
const {
|
|
listPluginPackages,
|
|
getPluginPackage,
|
|
upsertPluginPackage,
|
|
removePluginPackage,
|
|
listPluginRecords,
|
|
savePluginRecords,
|
|
listPluginRuntimeLogs,
|
|
savePluginRuntimeLogs,
|
|
readPluginData,
|
|
writePluginData
|
|
} = require("./storage");
|
|
const { onSessionEvent, sendToActiveSession } = require("./sessionBus");
|
|
|
|
const MAX_RUNTIME_LOGS = 300;
|
|
const SUPPORTED_PERMISSIONS = new Set([
|
|
"commands.register",
|
|
"session.read",
|
|
"session.write",
|
|
"ui.notice",
|
|
"storage.read",
|
|
"storage.write",
|
|
"logs.read"
|
|
]);
|
|
const DEFAULT_APP_META = {
|
|
version: "2.7.1",
|
|
platform: "miniapp"
|
|
};
|
|
|
|
const state = {
|
|
initialized: false,
|
|
records: new Map(),
|
|
runtime: new Map(),
|
|
commands: new Map(),
|
|
runtimeLogs: []
|
|
};
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function toLogLine(level, pluginId, message) {
|
|
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
return `[${time}] [${level}] [${pluginId}] ${message}`;
|
|
}
|
|
|
|
function pushRuntimeLog(level, pluginId, message) {
|
|
const line = toLogLine(level, pluginId, message);
|
|
state.runtimeLogs.unshift(line);
|
|
if (state.runtimeLogs.length > MAX_RUNTIME_LOGS) {
|
|
state.runtimeLogs.splice(MAX_RUNTIME_LOGS);
|
|
}
|
|
savePluginRuntimeLogs(state.runtimeLogs);
|
|
}
|
|
|
|
function createRecord(pluginId) {
|
|
const now = nowIso();
|
|
return {
|
|
id: pluginId,
|
|
enabled: false,
|
|
status: "discovered",
|
|
errorCount: 0,
|
|
lastError: "",
|
|
installedAt: now,
|
|
updatedAt: now,
|
|
lastLoadedAt: ""
|
|
};
|
|
}
|
|
|
|
function persistRecords() {
|
|
savePluginRecords(Array.from(state.records.values()));
|
|
}
|
|
|
|
function normalizeRecord(record) {
|
|
const base = createRecord(String((record && record.id) || ""));
|
|
return {
|
|
...base,
|
|
...(record || {}),
|
|
id: String((record && record.id) || base.id),
|
|
errorCount: Number((record && record.errorCount) || 0),
|
|
enabled: Boolean(record && record.enabled)
|
|
};
|
|
}
|
|
|
|
function safeParsePluginJson(raw) {
|
|
const parsed = JSON.parse(String(raw || ""));
|
|
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
return items.map(validatePluginPackage);
|
|
}
|
|
|
|
function assertPermission(manifest, permission) {
|
|
if (!manifest.permissions.includes(permission)) {
|
|
throw new Error(`权限不足: ${permission}`);
|
|
}
|
|
}
|
|
|
|
function validatePluginPackage(input) {
|
|
const payload = input && typeof input === "object" ? input : {};
|
|
const manifest = payload.manifest && typeof payload.manifest === "object" ? payload.manifest : {};
|
|
const id = String(manifest.id || "").trim();
|
|
if (!id) throw new Error("插件 manifest.id 不能为空");
|
|
const name = String(manifest.name || "").trim();
|
|
if (!name) throw new Error(`插件 ${id} 缺少 manifest.name`);
|
|
const permissions = Array.isArray(manifest.permissions)
|
|
? manifest.permissions.map((item) => String(item || "").trim())
|
|
: [];
|
|
permissions.forEach((permission) => {
|
|
if (!SUPPORTED_PERMISSIONS.has(permission)) {
|
|
throw new Error(`插件 ${id} 使用了未支持权限: ${permission}`);
|
|
}
|
|
});
|
|
const mainJs = String(payload.mainJs || "");
|
|
if (!mainJs.trim()) {
|
|
throw new Error(`插件 ${id} 缺少 mainJs`);
|
|
}
|
|
return {
|
|
manifest: {
|
|
id,
|
|
name,
|
|
version: String(manifest.version || "0.0.1"),
|
|
minAppVersion: String(manifest.minAppVersion || "0.0.1"),
|
|
description: String(manifest.description || ""),
|
|
entry: "main.js",
|
|
style: "styles.css",
|
|
permissions
|
|
},
|
|
mainJs,
|
|
stylesCss: String(payload.stylesCss || "")
|
|
};
|
|
}
|
|
|
|
function getAppMeta() {
|
|
try {
|
|
const app = getApp();
|
|
return {
|
|
version: String((app && app.globalData && app.globalData.appVersion) || DEFAULT_APP_META.version),
|
|
platform: "miniapp"
|
|
};
|
|
} catch {
|
|
return DEFAULT_APP_META;
|
|
}
|
|
}
|
|
|
|
function compareVersion(a, b) {
|
|
const left = String(a || "0.0.0")
|
|
.split(".")
|
|
.map((item) => Number(item) || 0);
|
|
const right = String(b || "0.0.0")
|
|
.split(".")
|
|
.map((item) => Number(item) || 0);
|
|
const max = Math.max(left.length, right.length);
|
|
for (let i = 0; i < max; i += 1) {
|
|
const l = left[i] || 0;
|
|
const r = right[i] || 0;
|
|
if (l > r) return 1;
|
|
if (l < r) return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function createRuntimeContext(pluginPackage, runtimeSlot) {
|
|
const manifest = pluginPackage.manifest;
|
|
return {
|
|
app: getAppMeta(),
|
|
commands: {
|
|
register(command) {
|
|
assertPermission(manifest, "commands.register");
|
|
if (!command || typeof command !== "object") {
|
|
throw new Error("命令定义非法");
|
|
}
|
|
const rawId = String(command.id || "").trim();
|
|
if (!rawId) {
|
|
throw new Error("命令 id 不能为空");
|
|
}
|
|
const fullId = `${manifest.id}:${rawId}`;
|
|
const item = {
|
|
id: fullId,
|
|
title: String(command.title || rawId),
|
|
when: command.when === "connected" ? "connected" : "always",
|
|
handler: typeof command.handler === "function" ? command.handler : () => {}
|
|
};
|
|
state.commands.set(fullId, item);
|
|
runtimeSlot.cleanupFns.push(() => state.commands.delete(fullId));
|
|
}
|
|
},
|
|
session: {
|
|
async send(input) {
|
|
assertPermission(manifest, "session.write");
|
|
await sendToActiveSession(String(input || ""), { source: "assist", txnId: createTxnId("plugin") });
|
|
},
|
|
on(eventName, handler) {
|
|
assertPermission(manifest, "session.read");
|
|
const off = onSessionEvent(eventName, handler);
|
|
runtimeSlot.cleanupFns.push(off);
|
|
return off;
|
|
}
|
|
},
|
|
storage: {
|
|
async get(key) {
|
|
assertPermission(manifest, "storage.read");
|
|
const data = readPluginData(manifest.id);
|
|
return data[String(key || "")];
|
|
},
|
|
async set(key, value) {
|
|
assertPermission(manifest, "storage.write");
|
|
const data = readPluginData(manifest.id);
|
|
data[String(key || "")] = value;
|
|
writePluginData(manifest.id, data);
|
|
}
|
|
},
|
|
ui: {
|
|
showNotice(message, level = "info") {
|
|
assertPermission(manifest, "ui.notice");
|
|
const title = String(message || "").slice(0, 7) || "插件通知";
|
|
wx.showToast({ title, icon: level === "error" ? "none" : "none" });
|
|
}
|
|
},
|
|
logger: {
|
|
info(...args) {
|
|
pushRuntimeLog("info", manifest.id, args.join(" "));
|
|
},
|
|
warn(...args) {
|
|
pushRuntimeLog("warn", manifest.id, args.join(" "));
|
|
},
|
|
error(...args) {
|
|
pushRuntimeLog("error", manifest.id, args.join(" "));
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function loadPluginApi(mainJs, ctx) {
|
|
const moduleRef = { exports: {} };
|
|
const exportsRef = moduleRef.exports;
|
|
const fn = new Function(
|
|
"ctx",
|
|
"module",
|
|
"exports",
|
|
`"use strict";
|
|
const window = undefined;
|
|
const document = undefined;
|
|
const localStorage = undefined;
|
|
${mainJs}
|
|
return module.exports;`
|
|
);
|
|
return fn(ctx, moduleRef, exportsRef) || moduleRef.exports;
|
|
}
|
|
|
|
function clearRuntimeById(pluginId) {
|
|
const runtime = state.runtime.get(pluginId);
|
|
if (!runtime) {
|
|
return;
|
|
}
|
|
if (runtime.api && typeof runtime.api.onunload === "function") {
|
|
try {
|
|
runtime.api.onunload();
|
|
} catch (error) {
|
|
pushRuntimeLog("warn", pluginId, `onunload 异常: ${String((error && error.message) || error)}`);
|
|
}
|
|
}
|
|
runtime.cleanupFns.forEach((off) => {
|
|
try {
|
|
off();
|
|
} catch (error) {
|
|
console.warn("[pluginRuntime.cleanup]", pluginId, error);
|
|
}
|
|
});
|
|
state.runtime.delete(pluginId);
|
|
Array.from(state.commands.keys()).forEach((commandId) => {
|
|
if (commandId.startsWith(`${pluginId}:`)) {
|
|
state.commands.delete(commandId);
|
|
}
|
|
});
|
|
}
|
|
|
|
function ensureSamplePlugin() {
|
|
if (listPluginPackages().length > 0) {
|
|
return;
|
|
}
|
|
upsertPluginPackage({
|
|
manifest: {
|
|
id: "codex-shortcuts",
|
|
name: "Codex Shortcuts",
|
|
version: "0.1.0",
|
|
minAppVersion: "0.1.0",
|
|
description: "提供常用 Codex 快捷命令",
|
|
entry: "main.js",
|
|
style: "styles.css",
|
|
permissions: ["commands.register", "session.write", "ui.notice"]
|
|
},
|
|
mainJs: `
|
|
module.exports = {
|
|
onload(ctx) {
|
|
ctx.commands.register({
|
|
id: "codex-doctor",
|
|
title: "Codex Doctor",
|
|
when: "connected",
|
|
async handler() {
|
|
await ctx.session.send("codex --doctor\\r");
|
|
}
|
|
});
|
|
ctx.ui.showNotice("codex-shortcuts 已加载", "info");
|
|
}
|
|
};
|
|
`.trim(),
|
|
stylesCss: ""
|
|
});
|
|
}
|
|
|
|
function createTxnId(prefix) {
|
|
return `${prefix || "plugin"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
async function ensureBootstrapped() {
|
|
if (state.initialized) {
|
|
return;
|
|
}
|
|
|
|
state.runtimeLogs = listPluginRuntimeLogs();
|
|
ensureSamplePlugin();
|
|
|
|
state.records.clear();
|
|
const storedRecords = listPluginRecords();
|
|
storedRecords.forEach((record) => {
|
|
const normalized = normalizeRecord(record);
|
|
if (normalized.id) {
|
|
state.records.set(normalized.id, normalized);
|
|
}
|
|
});
|
|
|
|
listPluginPackages().forEach((pluginPackage) => {
|
|
const pluginId = String((pluginPackage && pluginPackage.manifest && pluginPackage.manifest.id) || "");
|
|
if (!pluginId) return;
|
|
if (!state.records.has(pluginId)) {
|
|
state.records.set(pluginId, createRecord(pluginId));
|
|
}
|
|
});
|
|
persistRecords();
|
|
|
|
state.initialized = true;
|
|
|
|
const enabledIds = Array.from(state.records.values())
|
|
.filter((record) => record.enabled)
|
|
.map((record) => record.id);
|
|
for (const pluginId of enabledIds) {
|
|
try {
|
|
await enable(pluginId);
|
|
} catch (error) {
|
|
pushRuntimeLog("error", pluginId, `自动启用失败: ${String((error && error.message) || error)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function listRecords() {
|
|
return Array.from(state.records.values());
|
|
}
|
|
|
|
function listRuntimeLogs() {
|
|
return state.runtimeLogs.slice();
|
|
}
|
|
|
|
function listCommands(sessionState) {
|
|
return Array.from(state.commands.values()).filter((command) => {
|
|
if (command.when === "connected") {
|
|
return sessionState === "connected";
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function importJson(raw) {
|
|
await ensureBootstrapped();
|
|
const packages = safeParsePluginJson(raw);
|
|
packages.forEach((pluginPackage) => {
|
|
upsertPluginPackage(pluginPackage);
|
|
const pluginId = pluginPackage.manifest.id;
|
|
const record = state.records.get(pluginId) || createRecord(pluginId);
|
|
record.status = "validated";
|
|
record.lastError = "";
|
|
record.updatedAt = nowIso();
|
|
state.records.set(pluginId, record);
|
|
});
|
|
persistRecords();
|
|
}
|
|
|
|
async function exportJson() {
|
|
await ensureBootstrapped();
|
|
return JSON.stringify(listPluginPackages(), null, 2);
|
|
}
|
|
|
|
async function enable(pluginId) {
|
|
await ensureBootstrapped();
|
|
const id = String(pluginId || "").trim();
|
|
if (!id) {
|
|
throw new Error("插件 id 不能为空");
|
|
}
|
|
|
|
const pluginPackage = getPluginPackage(id);
|
|
if (!pluginPackage) {
|
|
throw new Error("插件不存在");
|
|
}
|
|
|
|
const appMeta = getAppMeta();
|
|
if (compareVersion(appMeta.version, pluginPackage.manifest.minAppVersion || "0.0.1") < 0) {
|
|
throw new Error(`当前版本 ${appMeta.version} 低于插件最低要求 ${pluginPackage.manifest.minAppVersion}`);
|
|
}
|
|
|
|
const record = state.records.get(id) || createRecord(id);
|
|
if (record.errorCount >= 3) {
|
|
throw new Error("插件已熔断,请先重载后再启用");
|
|
}
|
|
|
|
clearRuntimeById(id);
|
|
record.status = "loading";
|
|
record.lastError = "";
|
|
record.updatedAt = nowIso();
|
|
state.records.set(id, record);
|
|
persistRecords();
|
|
|
|
const runtimeSlot = {
|
|
pluginId: id,
|
|
cleanupFns: [],
|
|
api: null
|
|
};
|
|
|
|
try {
|
|
const ctx = createRuntimeContext(pluginPackage, runtimeSlot);
|
|
runtimeSlot.api = loadPluginApi(pluginPackage.mainJs, ctx);
|
|
if (runtimeSlot.api && typeof runtimeSlot.api.onload === "function") {
|
|
await Promise.resolve(runtimeSlot.api.onload(ctx));
|
|
}
|
|
state.runtime.set(id, runtimeSlot);
|
|
|
|
record.enabled = true;
|
|
record.status = "active";
|
|
record.lastError = "";
|
|
record.lastLoadedAt = nowIso();
|
|
record.updatedAt = nowIso();
|
|
state.records.set(id, record);
|
|
persistRecords();
|
|
pushRuntimeLog("info", id, "插件已启用");
|
|
} catch (error) {
|
|
clearRuntimeById(id);
|
|
record.enabled = false;
|
|
record.status = "failed";
|
|
record.errorCount = Number(record.errorCount || 0) + 1;
|
|
record.lastError = String((error && error.message) || error);
|
|
record.updatedAt = nowIso();
|
|
state.records.set(id, record);
|
|
persistRecords();
|
|
pushRuntimeLog("error", id, `启用失败: ${record.lastError}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function disable(pluginId) {
|
|
await ensureBootstrapped();
|
|
const id = String(pluginId || "").trim();
|
|
if (!id) {
|
|
throw new Error("插件 id 不能为空");
|
|
}
|
|
clearRuntimeById(id);
|
|
const record = state.records.get(id) || createRecord(id);
|
|
record.enabled = false;
|
|
if (record.status !== "failed") {
|
|
record.status = "stopped";
|
|
}
|
|
record.updatedAt = nowIso();
|
|
state.records.set(id, record);
|
|
persistRecords();
|
|
pushRuntimeLog("info", id, "插件已禁用");
|
|
}
|
|
|
|
async function reload(pluginId) {
|
|
await ensureBootstrapped();
|
|
const id = String(pluginId || "").trim();
|
|
const record = state.records.get(id) || createRecord(id);
|
|
record.errorCount = 0;
|
|
record.lastError = "";
|
|
record.updatedAt = nowIso();
|
|
state.records.set(id, record);
|
|
persistRecords();
|
|
await disable(id);
|
|
await enable(id);
|
|
}
|
|
|
|
async function remove(pluginId) {
|
|
await ensureBootstrapped();
|
|
const id = String(pluginId || "").trim();
|
|
await disable(id);
|
|
removePluginPackage(id);
|
|
state.records.delete(id);
|
|
persistRecords();
|
|
pushRuntimeLog("info", id, "插件已移除");
|
|
}
|
|
|
|
async function runCommand(commandId) {
|
|
await ensureBootstrapped();
|
|
const command = state.commands.get(String(commandId || ""));
|
|
if (!command) {
|
|
throw new Error("命令不存在");
|
|
}
|
|
await Promise.resolve(command.handler());
|
|
}
|
|
|
|
module.exports = {
|
|
ensureBootstrapped,
|
|
listRecords,
|
|
listRuntimeLogs,
|
|
listCommands,
|
|
importJson,
|
|
exportJson,
|
|
enable,
|
|
disable,
|
|
reload,
|
|
remove,
|
|
runCommand
|
|
};
|