Файли
l0ng-ai 953e844541 feat(plugins): community plugins, version compatibility and categories in the marketplace (#1226)
* feat(plugins): list community plugins from a registry in the marketplace

The marketplace index can now carry plugins of other publishers, published
from their authors' repositories through the l0ng-ai/openidea-plugins
registry.

- `openidea-plugin index` with a key replaces only that publisher's plugins
  (it used to drop every other entry); without a key it indexes each package
  against the publishers the index already lists, checking the key and the
  id prefix instead of only the signature itself.
- `openidea-plugin merge` takes another index's publishers, plugins and
  revocations in place of every publisher but `--keep`; the other index can't
  list or revoke the kept publishers' plugins.
- `openidea-plugin check` accepts a `.oiplugin` file.
- Releases merge the registry's index into plugins-index.json and attach the
  Linux x64 tool; plugins-index.yml refreshes the community plugins in the
  latest release hourly or when the registry signals a change.
- plugins/README.md and CONTRIBUTING.md describe official and community
  plugins.

* feat(plugins): offer the newest plugin version this IDE can load

Index entries record the plugin API each version needs (`engine`: the
manifest's `engines.openidea` narrowed to the API its component was built
for), and an index may list several versions of a plugin. Settings › Plugins
offers the newest version this IDE can load; with none, the plugin is shown
as incompatible with the API it needs, and downloading it is refused.

`openidea-plugin index` keeps, of each plugin, the newest version per API
requirement, so users of an older IDE keep the last version built for them;
`--download-base` makes downloads absolute, and the release workflow points
them at the release that published them. Scratch directories are now unique
per call.

* feat(plugins): file marketplace plugins under categories

`plugin.toml` takes `categories` (up to 3 of languages, themes, icon-themes,
linters, vcs, ai, frameworks, tools, other). A plugin that declares none is
filed under what its contributions imply (themes, icon themes, languages),
else under other; categories an IDE doesn't know are ignored, and `check`
warns about them. Index entries carry the categories, and Settings › Plugins
› Available filters by them and shows them in a plugin's details. The
official plugins declare theirs.

* feat(plugins): let a marketplace index point to where it moved

An index with `moved` sends the IDE to the index at that URL (relative or
absolute, up to three hops; an https index can only move to https). Downloads
resolve against the index actually read, so a marketplace can change hosts
while released IDEs keep the address they were built with.

* fix(plugins): full-width category filter and aligned incompatibility note

* fix(plugins): translate the AI category in Chinese

* feat(plugins): keep a plugin's signer across updates and announce updates

- Index format: fields are only added; an IDE reading a newer `format`
  still applies its revocations and `moved`, lists no plugins and asks to
  be updated, instead of failing to read the index.
- Key continuity: an installed plugin updates silently only to a package
  signed with the same key, or a key it endorsed (`KeyEntry.endorsement`,
  `index --previous-key`, `openidea-plugin endorse`). Another signer, or an
  unsigned package replacing a signed one, is shown on the install page and
  needs the user's confirmation (`Consent.signerChange`).
- Official key: release builds carry the official publisher's public key
  (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from PLUGIN_SIGNING_KEY);
  `openidea.*` plugins must be signed with it or a key it endorsed,
  whatever the index lists.
- Updates: the daily marketplace check announces new versions once, with
  Update All (also in the gear menu and as an action). An update that asks
  for nothing new installs with the grants the installed version has.
- Index entries need `engine`; `categories` beyond the first three are
  ignored instead of rejecting the manifest.
2026-11-04 18:24:43 +08:00

150 рядки
5.2 KiB
TypeScript

/**
* The install / permission dialog: every
* capability with its risk; optional ones can be refused; high-risk ones are
* called out as running code on this machine.
*/
import { useState } from "react";
import { create } from "zustand";
import type { CapabilityInfo, Consent } from "@/bindings";
import { tx } from "@/platform/i18n";
import { Badge, Checkbox, Dialog, T } from "@/ui";
import { setConsentPrompt, type ConsentRequest } from "./store";
import styles from "./PluginsPage.module.css";
interface Pending {
req: ConsentRequest;
resolve: (c: Consent | null) => void;
}
const usePending = create<{ queue: Pending[] }>(() => ({ queue: [] }));
export const RISK_LABEL: Record<CapabilityInfo["risk"], { label: string; tone: "neutral" | "warning" | "danger" }> = {
low: { label: "Low", tone: "neutral" },
medium: { label: "Medium", tone: "warning" },
high: { label: "High", tone: "danger" },
};
const RISK_ORDER = { high: 0, medium: 1, low: 2 } as const;
export function sortByRisk(caps: readonly CapabilityInfo[]): CapabilityInfo[] {
return [...caps].sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk] || a.id.localeCompare(b.id));
}
function ask(req: ConsentRequest): Promise<Consent | null> {
return new Promise((resolve) => usePending.setState((s) => ({ queue: [...s.queue, { req, resolve }] })));
}
function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => void }) {
const { req } = pending;
const caps = sortByRisk(req.capabilities);
const optional = caps.filter((c) => c.optional);
const [accepted, setAccepted] = useState<Set<string>>(
() => new Set(optional.filter((c) => c.granted || c.risk === "low").map((c) => c.id)),
);
const high = caps.some((c) => c.risk === "high" && (!c.optional || accepted.has(c.id)));
const finish = (consent: Consent | null) => {
pending.resolve(consent);
onDone();
};
const title = req.kind === "install" ? tx`Install ${req.name} ${req.version}?` : tx`Allow ${req.name} to run?`;
return (
<Dialog
open
title={title}
icon={high ? "warning" : "question"}
width={520}
onClose={() => finish(null)}
buttons={[
{ label: tx("Cancel"), role: "cancel", onClick: () => finish(null) },
{
label: req.kind === "install" ? tx("Install") : tx("Allow"),
role: "default",
onClick: () => finish({ optional: [...accepted], signerChange: !!req.signerChanged }),
},
]}
>
<div className={styles.consent}>
{req.unsigned && (
<p className={styles.consentWarning} role="note">
<T>This package is not signed. Only continue if you trust where it came from.</T>
</p>
)}
{req.signerChanged && (
<p className={styles.consentWarning} role="alert">
<T>
This version is signed with a different key than the installed one, and the publisher's earlier key
doesn't vouch for it. Only continue if you know the publisher changed keys.
</T>
</p>
)}
{caps.length === 0 ? (
<p>
<T>This plugin needs no special permissions.</T>
</p>
) : (
<ul className={styles.items} aria-label={tx("Requested permissions")}>
{caps.map((c) => (
<li key={c.id} className={styles.item}>
{c.optional ? (
<Checkbox
checked={accepted.has(c.id)}
onChange={(on) =>
setAccepted((s) => {
const next = new Set(s);
if (on) next.add(c.id);
else next.delete(c.id);
return next;
})
}
aria-label={tx`Allow ${c.id}`}
/>
) : (
<span className={styles.consentRequired} title={tx("Required")}>
•
</span>
)}
<span className={styles.itemMain}>{c.description}</span>
<span className={styles.mono}>{c.id}</span>
<Badge tone={RISK_LABEL[c.risk].tone}>{RISK_LABEL[c.risk].label}</Badge>
</li>
))}
</ul>
)}
{high && (
<p className={styles.consentWarning} role="alert">
<T>High-risk permissions are equivalent to running arbitrary code on this machine.</T>
</p>
)}
{req.warnings.map((w) => (
<p key={w} className={styles.muted}>
{w}
</p>
))}
</div>
</Dialog>
);
}
/** Overlay rendering the queued consent dialogs one at a time. */
export function ConsentDialogHost() {
const first = usePending((s) => s.queue[0]);
if (!first) return null;
return (
<ConsentDialog
key={first.req.pluginId + first.req.kind}
pending={first}
onDone={() => usePending.setState((s) => ({ queue: s.queue.slice(1) }))}
/>
);
}
/** Routes the store's consent requests to the dialog. */
export function provideConsentDialog(): () => void {
setConsentPrompt(ask);
return () => {
setConsentPrompt(null);
for (const p of usePending.getState().queue) p.resolve(null);
usePending.setState({ queue: [] });
};
}