Files
openidea/src/features/plugins/store.ts
T
l0ng-ai 3dc85d4ffe fix(plugins): list the marketplace and install plugins from the welcome screen
The Plugins page on the welcome screen showed "[object Object]" and "There
is no online plugin repository" (even with a marketplace configured), and
Install did nothing.

- Marketplace and package commands (index, download, unpack, install
  preview, theme import) only touch the IDE's own state and are allowed
  without a trusted project.
- Installing, uninstalling, enabling and granting plugins are allowed
  where no project is open; an untrusted project still blocks them.
- Errors rejected as plain backend error objects show their message, and
  a marketplace that couldn't be read shows the error instead of the
  "no online repository" note.
2026-11-04 19:55:50 +08:00

410 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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,
PluginInfo,
PluginStatus,
UiContribution,
} from "@/bindings";
import { notifications } from "@/workbench/contributions";
import { pluginsBackend, type PluginsBackend } from "./backend";
import { editorContext } from "./editorContext";
import { iconThemeKey, selectedIconTheme, selectIconTheme } from "./iconThemes";
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,
}));
/** A rejection's message: an `Error`, or an `IdeError` object from a command without a result type. */
export const errorText = (e: unknown): string =>
e instanceof Error
? e.message
: typeof e === "object" && e !== null && "message" in e && typeof e.message === "string"
? 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;
/** Replaces an installed copy signed with another key that didn't vouch for this one. */
signerChanged?: 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 async function adoptIconTheme(pluginId: string): Promise<void> {
if (selectedIconTheme()) return;
const first = usePlugins.getState().ui.find((c) => c.pluginId === pluginId && c.point === "iconThemes");
const id = (first?.decl as { id?: unknown } | undefined)?.id;
if (typeof id === "string") await selectIconTheme(iconThemeKey(pluginId, id));
}
export async function setPluginEnabled(id: string, enabled: boolean): Promise<boolean> {
const done = await withBusy(id, `Could not ${enabled ? "enable" : "disable"} ${nameOf(id)}`, () =>
pluginsBackend().setEnabled(id, enabled),
);
if (done && enabled) await adoptIconTheme(id);
return done;
}
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);
}
/** A version this IDE can load, newer than the one installed. */
export function isUpdate(entry: MarketPlugin): boolean {
return entry.compatible && !!entry.installed && compareVersions(entry.version, entry.installed) > 0;
}
/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */
export async function installFromMarket(id: string, options: InstallOptions = {}): 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, false, options);
void refreshMarket(false);
return installed;
}
/**
* Installs every update the marketplace offers, one after another. Updates
* that need nothing confirmed install right away; the others show their
* install page. Returns how many were installed.
*/
export async function updateAllPlugins(): Promise<number> {
await refreshMarket(true);
const updates = (usePlugins.getState().market ?? []).filter(isUpdate);
let updated = 0;
for (const entry of updates) if (await installFromMarket(entry.id, { quiet: true })) updated++;
if (updated > 0) {
notifications().notify({ group: "plugins", level: "info", title: `Updated ${updated} plugins` });
}
return updated;
}
/** 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;
}
/** The folder to install from for what the user picked; `.oiplugin` / `.zip` files are unpacked. */
async function packageFolder(backend: PluginsBackend, picked: string): Promise<string> {
const lower = picked.toLowerCase();
if (lower.endsWith(".oiplugin") || lower.endsWith(".zip")) return backend.unpackPackage(picked);
// A folder's manifest stands for the folder.
const manifest = /[\\/]plugin\.toml$/i.exec(picked);
return manifest ? picked.slice(0, manifest.index) : picked;
}
interface InstallOptions {
/** No notification when it installs (a batch reports once). */
quiet?: boolean;
}
/**
* Asks for a plugin (a folder, its `plugin.toml`, or an `.oiplugin` / `.zip`
* package file), shows what it may do, and installs it with the user's
* consent. An update with nothing to confirm installs with the grants it
* has. Returns the new plugin id.
*/
export async function installFromDisk(
path?: string,
packageFile = false,
options: InstallOptions = {},
): Promise<string | null> {
const backend = pluginsBackend();
let dir: string | null = path ?? null;
try {
dir ??= packageFile ? await backend.pickPackage() : await backend.pickFolder();
// Package files are unpacked first; signed ones are checked on the install page.
if (dir) dir = await packageFolder(backend, dir);
} catch (e) {
notifications().notify({
group: "plugins",
level: "error",
title: packageFile ? "Could not open the package" : "Could not open the file picker",
body: errorText(e),
});
return null;
}
if (!dir) return null;
usePlugins.setState({ installing: true });
let installed: string | null = null;
try {
const preview = await backend.previewInstall(dir);
const consent: Consent | null = preview.needsConsent
? await askConsent({
pluginId: preview.id,
name: preview.name,
version: preview.version,
kind: "install",
capabilities: preview.capabilities,
unsigned: preview.unsigned,
signerChanged: preview.signerChanged,
warnings: preview.warnings,
})
: { optional: preview.capabilities.filter((c) => c.optional && c.granted).map((c) => c.id) };
if (!consent) return null;
const info = await backend.installDir(dir, consent);
installed = info.status === "failed" ? null : info.id;
if (info.status === "failed" || !options.quiet) {
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();
if (installed) await adoptIconTheme(installed);
}
}
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;
}
/** 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, editorContext());
} 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;
}
}