Файли
openidea/src/features/plugins/store.ts
T
l0ng-ai fd1f465aeb feat(plugins): extension points, capability consent, async runtime and plugin API 1.0
Rework the plugin system per docs/plugin_plan.md (P0–P8):

- Extension-point registry (ide-extension) so new contribution types register
  without touching the kernel; languages, run detectors, providers, views and
  UI contributions go through it.
- Per-plugin capability consent with a risk-graded ledger; process
  capabilities are pinned to resolved binary paths and hashes.
- Async, actor-per-plugin Wasm runtime with lazy activation, cancellation,
  events, long-running tasks and idle reclamation.
- Plugin host moved into ide-host, shared by local and remote projects.
- Distribution: install/package tooling (openidea-plugin bin) and a static
  market index.
- WIT frozen as openidea:[email protected]; Rust SDK with mock host.
- Ten official validation plugins under plugins/official.
2026-11-04 02:41:46 +08:00

360 рядки
12 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */
import { create } from "zustand";
import type {
ActiveTheme,
CapabilityInfo,
CatalogPlugin,
Consent,
MarketPlugin,
PluginEditor,
PluginInfo,
PluginStatus,
UiContribution,
} from "@/bindings";
import { activeEditor } from "@/editor/api";
import { notifications } from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
export const SETTINGS_PAGE_ID = "plugins";
interface PluginsState {
plugins: PluginInfo[];
/** UI contributions of live plugins, projected by `projection.ts`. */
ui: UiContribution[];
activeTheme: ActiveTheme | null;
loaded: boolean;
loading: boolean;
error: string | null;
/** Plugin ids with an operation in flight. */
busy: Readonly<Record<string, boolean>>;
installing: boolean;
/** Locally available packages (Marketplace tab); `null` until loaded. */
catalog: CatalogPlugin[] | null;
catalogError: string | null;
/** The marketplace index URL; `null` when none is configured. */
marketUrl: string | null;
/** Plugins of the marketplace index; `null` until read. */
market: MarketPlugin[] | null;
marketError: string | null;
}
export const usePlugins = create<PluginsState>(() => ({
plugins: [],
ui: [],
activeTheme: null,
loaded: false,
loading: false,
error: null,
busy: {},
installing: false,
catalog: null,
catalogError: null,
marketUrl: null,
market: null,
marketError: null,
}));
export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** Statuses whose contributions are in effect. */
export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static";
// --- consent ------------------------------------------------------------------
/** What a consent prompt shows: an install preview or a plugin waiting for permission. */
export interface ConsentRequest {
pluginId: string;
name: string;
version: string;
/** Install page (new or update) or a permission prompt for an installed/project plugin. */
kind: "install" | "grant";
capabilities: CapabilityInfo[];
/** Unsigned package (from disk or a project folder). */
unsigned: boolean;
warnings: string[];
}
type ConsentPrompt = (req: ConsentRequest) => Promise<Consent | null>;
let consentPrompt: ConsentPrompt | null = null;
/** The dialog that asks (registered by `consent.tsx`; tests substitute their own). */
export function setConsentPrompt(prompt: ConsentPrompt | null) {
consentPrompt = prompt;
}
/** Without a prompt only plugins asking for nothing are accepted. */
function askConsent(req: ConsentRequest): Promise<Consent | null> {
if (consentPrompt) return consentPrompt(req);
return Promise.resolve(req.capabilities.length === 0 ? { optional: [] } : null);
}
// --- loading ------------------------------------------------------------------
let generation = 0;
export async function refreshPlugins(): Promise<void> {
const gen = ++generation;
usePlugins.setState({ loading: true });
try {
const snapshot = await pluginsBackend().list();
if (gen !== generation) return;
usePlugins.setState({
plugins: snapshot.plugins,
ui: snapshot.ui,
activeTheme: snapshot.activeTheme,
loaded: true,
loading: false,
error: null,
});
} catch (e) {
if (gen !== generation) return;
usePlugins.setState({ loading: false, loaded: true, error: errorText(e) });
}
}
async function withBusy(id: string, title: string, op: () => Promise<unknown>): Promise<boolean> {
usePlugins.setState((s) => ({ busy: { ...s.busy, [id]: true } }));
try {
await op();
return true;
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title, body: errorText(e) });
return false;
} finally {
usePlugins.setState((s) => {
const busy = { ...s.busy };
delete busy[id];
return { busy };
});
await refreshPlugins();
}
}
const nameOf = (id: string) => usePlugins.getState().plugins.find((p) => p.id === id)?.name ?? id;
export function setPluginEnabled(id: string, enabled: boolean): Promise<boolean> {
return withBusy(id, `Could not ${enabled ? "enable" : "disable"} ${nameOf(id)}`, () =>
pluginsBackend().setEnabled(id, enabled),
);
}
export function reloadPlugin(id: string): Promise<boolean> {
return withBusy(id, `Could not reload ${nameOf(id)}`, () => pluginsBackend().reload(id));
}
export function uninstallPlugin(id: string): Promise<boolean> {
const name = nameOf(id);
return withBusy(id, `Could not uninstall ${name}`, async () => {
await pluginsBackend().uninstall(id);
notifications().notify({ group: "plugins", level: "info", title: `Uninstalled ${name}` });
});
}
/** Asks for (and records) the permissions of a plugin waiting for consent. */
export async function grantPlugin(id: string, capabilities?: CapabilityInfo[]): Promise<boolean> {
const plugin = usePlugins.getState().plugins.find((p) => p.id === id);
if (!plugin) return false;
const consent = await askConsent({
pluginId: id,
name: plugin.name,
version: plugin.version,
kind: "grant",
capabilities: capabilities ?? plugin.capabilities,
unsigned: plugin.source !== "installed",
warnings: plugin.warnings,
});
if (!consent) return false;
return withBusy(id, `Could not enable ${plugin.name}`, () => pluginsBackend().grant(id, consent));
}
export function setPluginCapability(id: string, capability: string, granted: boolean): Promise<boolean> {
return withBusy(id, `Could not change ${capability} of ${nameOf(id)}`, () =>
pluginsBackend().setCapability(id, capability, granted),
);
}
export async function refreshCatalog(): Promise<void> {
try {
usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null });
} catch (e) {
usePlugins.setState({ catalog: [], catalogError: errorText(e) });
}
}
/** The marketplace's plugins: the index read again with `refresh`. */
export async function refreshMarket(refresh = true): Promise<void> {
const backend = pluginsBackend();
try {
const marketUrl = await backend.marketUrl();
usePlugins.setState({ marketUrl });
if (!marketUrl) return usePlugins.setState({ market: null, marketError: null });
usePlugins.setState({ market: await backend.market(refresh), marketError: null });
} catch (e) {
usePlugins.setState({ marketError: errorText(e) });
}
}
export async function setMarketUrl(url: string | null): Promise<void> {
await pluginsBackend().setMarketUrl(url?.trim() || null);
await refreshMarket(true);
}
/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */
export async function installFromMarket(id: string): Promise<string | null> {
usePlugins.setState({ installing: true });
let dir: string;
try {
dir = await pluginsBackend().marketDownload(id);
} catch (e) {
usePlugins.setState({ installing: false });
notifications().notify({
group: "plugins",
level: "error",
title: "Could not download plugin",
body: errorText(e),
});
return null;
}
const installed = await installFromDisk(dir);
void refreshMarket(false);
return installed;
}
/** Settings › Plugins › Roll Back: the version before the last update. */
export async function rollBackPlugin(id: string): Promise<void> {
try {
const info = await pluginsBackend().rollBack(id);
notifications().notify({ group: "plugins", level: "info", title: `Rolled back ${info.name} to ${info.version}` });
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not roll back", body: errorText(e) });
} finally {
await refreshPlugins();
}
}
/** Enables or disables every installed (non-dev) plugin; returns how many changed. */
export async function setAllInstalledEnabled(enabled: boolean): Promise<number> {
const targets = usePlugins.getState().plugins.filter((p) => p.source === "installed" && p.enabled !== enabled);
let changed = 0;
for (const p of targets) if (await setPluginEnabled(p.id, enabled)) changed++;
return changed;
}
/** Numeric dotted-version comparison (`1.10.0` > `1.9.2`); pre-release suffixes are ignored. */
export function compareVersions(a: string, b: string): number {
const parts = (v: string) =>
v
.split(/[-+]/)[0]!
.split(".")
.map((x) => parseInt(x, 10) || 0);
const [x, y] = [parts(a), parts(b)];
for (let i = 0; i < Math.max(x.length, y.length); i++) {
const d = (x[i] ?? 0) - (y[i] ?? 0);
if (d) return Math.sign(d);
}
return 0;
}
/**
* Asks for a plugin folder (containing plugin.toml), shows what it may do,
* and installs it with the user's consent. Returns the new plugin id.
*/
export async function installFromDisk(path?: string, packageFile = false): Promise<string | null> {
const backend = pluginsBackend();
let dir: string | null = path ?? null;
try {
dir ??= packageFile ? await backend.pickPackage() : await backend.pickFolder();
// A package file (`.oiplugin`) is unpacked first; signed ones are checked on the install page.
if (dir?.endsWith(".oiplugin")) dir = await backend.unpackPackage(dir);
} catch (e) {
notifications().notify({
group: "plugins",
level: "error",
title: packageFile ? "Could not open the package" : "Could not open the folder picker",
body: errorText(e),
});
return null;
}
if (!dir) return null;
usePlugins.setState({ installing: true });
try {
const preview = await backend.previewInstall(dir);
const consent = await askConsent({
pluginId: preview.id,
name: preview.name,
version: preview.version,
kind: "install",
capabilities: preview.capabilities,
unsigned: preview.unsigned,
warnings: preview.warnings,
});
if (!consent) return null;
const info = await backend.installDir(dir, consent);
notifications().notify({
group: "plugins",
level: info.status === "failed" ? "warning" : "info",
title: `Installed ${info.name} ${info.version}`,
body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
});
return info.id;
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not install plugin", body: errorText(e) });
return null;
} finally {
usePlugins.setState({ installing: false });
await refreshPlugins();
}
}
export async function selectPluginTheme(pluginId: string | null, themeId: string | null): Promise<void> {
try {
const activeTheme = await pluginsBackend().setTheme(pluginId, themeId);
usePlugins.setState({ activeTheme });
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not apply theme", body: errorText(e) });
}
}
/** Title of a plugin command from its UI contribution. */
export function commandTitle(pluginId: string, commandId: string): string {
const item = usePlugins
.getState()
.ui.find((c) => c.point === "commands" && c.pluginId === pluginId && c.key === commandId);
const title = (item?.decl as { title?: unknown } | undefined)?.title;
return typeof title === "string" ? title : commandId;
}
/** The focused text editor as a command's context: path, language, selections (line / UTF-16 column). */
function focusedEditor(): PluginEditor | null {
const editor = activeEditor();
if (!editor?.ctx.path) return null;
const { doc, selection } = editor.view.state;
const pos = (offset: number) => {
const line = doc.lineAt(offset);
return { line: line.number - 1, col: offset - line.from };
};
return {
path: editor.ctx.path,
language: editor.ctx.language,
selections: selection.ranges.map((r) => ({ start: pos(r.from), end: pos(r.to) })),
};
}
/** Runs a plugin command from an action; failures become notifications. */
export async function runPluginCommand(pluginId: string, commandId: string, arg: string | null = null) {
const title = commandTitle(pluginId, commandId);
try {
return await pluginsBackend().runCommand(pluginId, commandId, arg, focusedEditor());
} catch (e) {
await refreshPlugins();
const status = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status;
// A trap disables the plugin and the backend already announced why; a
// consent request opens its own dialog.
const permission = (e as { kind?: string }).kind === "permissionDenied";
if (status !== "failed" && !permission)
notifications().notify({ group: "plugins", level: "error", title: `${title} failed`, body: errorText(e) });
return null;
}
}