дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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.
Цей коміт міститься в:
1 джерело
48e23c012f
коміт
953e844541
61 files changed
+2030
-160
No files matched your search
@@ -380,6 +380,14 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.categorySelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detailsNotice {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.marketOverview {
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconButton,
|
||||
Input,
|
||||
SearchField,
|
||||
Select,
|
||||
Spinner,
|
||||
Tabs,
|
||||
showContextMenu,
|
||||
@@ -28,6 +29,7 @@ import { RISK_LABEL, sortByRisk } from "./consent";
|
||||
import { itemsOf, uiPoint } from "./projection";
|
||||
import {
|
||||
compareVersions,
|
||||
isUpdate,
|
||||
grantPlugin,
|
||||
installFromDisk,
|
||||
installFromMarket,
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
setPluginCapability,
|
||||
setPluginEnabled,
|
||||
uninstallPlugin,
|
||||
updateAllPlugins,
|
||||
usePlugins,
|
||||
} from "./store";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -536,9 +539,15 @@ interface GearActions {
|
||||
importInstalledIntellij: () => void;
|
||||
}
|
||||
|
||||
function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuItem[] {
|
||||
function gearMenu(plugins: readonly PluginInfo[], updates: number, actions: GearActions): MenuItem[] {
|
||||
const installed = plugins.filter((p) => p.source === "installed");
|
||||
return [
|
||||
{
|
||||
label: updates > 0 ? tx`Update All Plugins (${updates})` : "Update All Plugins",
|
||||
disabled: updates === 0,
|
||||
run: () => void updateAllPlugins(),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) },
|
||||
{ label: "Install Plugin from Package File…", run: () => actions.install(true) },
|
||||
...(actions.canImport
|
||||
@@ -572,6 +581,79 @@ function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuIte
|
||||
];
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
"languages",
|
||||
"themes",
|
||||
"icon-themes",
|
||||
"linters",
|
||||
"vcs",
|
||||
"ai",
|
||||
"frameworks",
|
||||
"tools",
|
||||
"other",
|
||||
] as const;
|
||||
type Category = (typeof CATEGORIES)[number];
|
||||
|
||||
function categoryLabel(c: Category): string {
|
||||
switch (c) {
|
||||
case "languages":
|
||||
return tx("Languages");
|
||||
case "themes":
|
||||
return tx("Themes");
|
||||
case "icon-themes":
|
||||
return tx("Icon Themes");
|
||||
case "linters":
|
||||
return tx("Linters & Formatters");
|
||||
case "vcs":
|
||||
return tx("Version Control");
|
||||
case "ai":
|
||||
return tx("AI");
|
||||
case "frameworks":
|
||||
return tx("Frameworks");
|
||||
case "tools":
|
||||
return tx("Tools");
|
||||
case "other":
|
||||
return tx("Other");
|
||||
}
|
||||
}
|
||||
|
||||
/** A marketplace plugin's categories this IDE knows; `other` without any. */
|
||||
function categoriesOf(m: MarketPlugin): Category[] {
|
||||
const known = CATEGORIES.filter((c) => m.categories?.includes(c));
|
||||
return known.length > 0 ? known : ["other"];
|
||||
}
|
||||
|
||||
/** Filters the marketplace by category; lists the categories that have plugins. */
|
||||
function CategoryFilter({
|
||||
market,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
market: readonly MarketPlugin[];
|
||||
value: Category | "all";
|
||||
onChange: (value: Category | "all") => void;
|
||||
}) {
|
||||
const counts = new Map<Category, number>();
|
||||
for (const m of market) for (const c of categoriesOf(m)) counts.set(c, (counts.get(c) ?? 0) + 1);
|
||||
const options = [
|
||||
{ value: "all" as const, label: tx("All Categories"), detail: String(market.length) },
|
||||
...CATEGORIES.filter((c) => counts.has(c)).map((c) => ({
|
||||
value: c,
|
||||
label: categoryLabel(c),
|
||||
detail: String(counts.get(c)),
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
aria-label={tx("Category")}
|
||||
className={styles.categorySelect}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The marketplace index URL, editable in place. */
|
||||
function MarketUrlBar() {
|
||||
const current = usePlugins((s) => s.marketUrl);
|
||||
@@ -597,7 +679,7 @@ function MarketUrlBar() {
|
||||
}
|
||||
|
||||
function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) {
|
||||
const update = entry.installed && compareVersions(entry.version, entry.installed) > 0;
|
||||
const update = isUpdate(entry);
|
||||
return (
|
||||
<li className={clsx(styles.row, selected && styles.rowSelected)}>
|
||||
<button
|
||||
@@ -620,6 +702,8 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte
|
||||
<Badge tone="accent">{tx("Update")}</Badge>
|
||||
) : entry.installed ? (
|
||||
<Badge tone="neutral">{tx("Installed")}</Badge>
|
||||
) : !entry.compatible ? (
|
||||
<Badge tone="warning">{tx("Incompatible")}</Badge>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
@@ -627,7 +711,7 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte
|
||||
|
||||
function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) {
|
||||
const installing = usePlugins((s) => s.installing);
|
||||
const update = entry.installed && compareVersions(entry.version, entry.installed) > 0;
|
||||
const update = isUpdate(entry);
|
||||
const install = async () => {
|
||||
const id = await installFromMarket(entry.id);
|
||||
if (id) onInstalled(id);
|
||||
@@ -645,7 +729,7 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
{(!entry.installed || update) && (
|
||||
{entry.compatible && (!entry.installed || update) && (
|
||||
<Button size="small" variant="primary" disabled={installing} onClick={() => void install()}>
|
||||
{update ? tx`Update to ${entry.version}` : tx("Install")}
|
||||
</Button>
|
||||
@@ -653,12 +737,22 @@ function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalle
|
||||
</div>
|
||||
</header>
|
||||
<div className={styles.marketOverview}>
|
||||
{!entry.compatible && (
|
||||
<p className={clsx(styles.notice, styles.detailsNotice)} role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<span>{tx`Needs plugin API ${entry.engine}. Update OpenIDEA to install it.`}</span>
|
||||
</p>
|
||||
)}
|
||||
{entry.description && <p className={styles.description}>{entry.description}</p>}
|
||||
<dl className={styles.facts}>
|
||||
<dt>
|
||||
<T>Id</T>
|
||||
</dt>
|
||||
<dd className={styles.mono}>{entry.id}</dd>
|
||||
<dt>
|
||||
<T>Category</T>
|
||||
</dt>
|
||||
<dd>{categoriesOf(entry).map(categoryLabel).join(", ")}</dd>
|
||||
{entry.installed && (
|
||||
<>
|
||||
<dt>
|
||||
@@ -698,13 +792,15 @@ export function PluginsPage() {
|
||||
void refreshMarket(false);
|
||||
}, []);
|
||||
const [selectedMarketId, setSelectedMarketId] = useState<string | null>(null);
|
||||
const [category, setCategory] = useState<Category | "all">("all");
|
||||
const marketShown = useMemo(
|
||||
() =>
|
||||
(market ?? []).filter((m) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (category !== "all" && !categoriesOf(m).includes(category)) return false;
|
||||
return !q || matches([m.name, m.id, m.description, m.publisher, ...(m.authors ?? [])], q);
|
||||
}),
|
||||
[market, query],
|
||||
[market, query, category],
|
||||
);
|
||||
const selectedMarket = marketShown.find((m) => m.id === selectedMarketId) ?? null;
|
||||
|
||||
@@ -714,7 +810,11 @@ export function PluginsPage() {
|
||||
const linked = shown.filter((p) => p.source === "linked");
|
||||
const selected = shown.find((p) => p.id === selectedId) ?? shown[0] ?? null;
|
||||
|
||||
const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]);
|
||||
// Local packages have no categories: shown only without a category filter.
|
||||
const catalogShown = useMemo(
|
||||
() => (category === "all" ? filterCatalog(catalog ?? [], query) : []),
|
||||
[catalog, query, category],
|
||||
);
|
||||
const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null;
|
||||
|
||||
const showInstalled = (id: string | null) => {
|
||||
@@ -727,7 +827,7 @@ export function PluginsPage() {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu(
|
||||
{ x: r.left, y: r.bottom + 2 },
|
||||
gearMenu(plugins, {
|
||||
gearMenu(plugins, (market ?? []).filter(isUpdate).length, {
|
||||
canImport: useProject.getState().project?.trusted === true,
|
||||
install: (packageFile) => void installFromDisk(undefined, packageFile).then(showInstalled),
|
||||
importIntellijFile: () => void importIntellijTheme().then(showInstalled),
|
||||
@@ -819,6 +919,11 @@ export function PluginsPage() {
|
||||
<li className={styles.marketItem}>
|
||||
<MarketUrlBar />
|
||||
</li>
|
||||
{(market?.length ?? 0) > 0 && (
|
||||
<li className={styles.marketItem}>
|
||||
<CategoryFilter market={market ?? []} value={category} onChange={setCategory} />
|
||||
</li>
|
||||
)}
|
||||
{!marketUrl && (
|
||||
<li className={styles.notice} role="note">
|
||||
<Icon name="info" size={14} />
|
||||
@@ -847,7 +952,7 @@ export function PluginsPage() {
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{catalog && catalogShown.length === 0 && (
|
||||
{catalog && category === "all" && catalogShown.length === 0 && (
|
||||
<li className={styles.listEmpty}>
|
||||
{catalog.length === 0 ? tx("No local plugin packages.") : tx("No plugins match.")}
|
||||
</li>
|
||||
|
||||
@@ -63,6 +63,8 @@ function backend(overrides: Partial<PluginsBackend> = {}): PluginsBackend {
|
||||
replaces: null,
|
||||
unsigned: true,
|
||||
publisher: null,
|
||||
signerChanged: false,
|
||||
needsConsent: true,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { PluginInfo, ProjectInfo } from "@/bindings";
|
||||
import type { Consent, InstallPreview, MarketPlugin, PluginInfo, ProjectInfo } from "@/bindings";
|
||||
import { _setActiveEditor } from "@/editor/api";
|
||||
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -30,10 +30,12 @@ import { useTasks } from "@/workbench/tasks";
|
||||
import { defineBuiltinUiPoints } from "../points";
|
||||
import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection";
|
||||
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
|
||||
import { ConsentDialogHost, provideConsentDialog } from "../consent";
|
||||
import {
|
||||
adoptIconTheme,
|
||||
compareVersions,
|
||||
installFromDisk,
|
||||
updateAllPlugins,
|
||||
refreshPlugins,
|
||||
runPluginCommand,
|
||||
selectPluginTheme,
|
||||
@@ -621,7 +623,9 @@ describe("marketplace", () => {
|
||||
download: "hello.oiplugin",
|
||||
digest: "x",
|
||||
capabilities: ["editor.write"],
|
||||
engine: "^1.0",
|
||||
installed: null,
|
||||
compatible: true,
|
||||
},
|
||||
],
|
||||
// The memory backend installs its sample packages by path.
|
||||
@@ -644,6 +648,206 @@ describe("marketplace", () => {
|
||||
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" });
|
||||
});
|
||||
|
||||
it("filters the marketplace by category", async () => {
|
||||
const entry = (id: string, name: string, categories: string[]) => ({
|
||||
id,
|
||||
name,
|
||||
version: "1.0.0",
|
||||
description: "",
|
||||
authors: [],
|
||||
publisher: "acme",
|
||||
download: `${id}.oiplugin`,
|
||||
digest: "x",
|
||||
capabilities: [],
|
||||
engine: "^1.0",
|
||||
categories,
|
||||
installed: null,
|
||||
compatible: true,
|
||||
});
|
||||
setPluginsBackend({
|
||||
...createMemoryPluginsBackend([], sampleCatalog()),
|
||||
marketUrl: async () => "https://plugins.example/index.json",
|
||||
market: async () => [
|
||||
entry("acme.dusk", "Dusk", ["themes"]),
|
||||
entry("acme.lint", "Lint", ["linters", "tools"]),
|
||||
entry("acme.misc", "Misc", ["robots"]),
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<PluginsPage />);
|
||||
await user.click(screen.getByRole("tab", { name: /Available/ }));
|
||||
const list = await screen.findByRole("list", { name: "Available plugins" });
|
||||
await waitFor(() => expect(within(list).getByText("Dusk")).toBeInTheDocument());
|
||||
await user.click(within(list).getByRole("button", { name: "Category" }));
|
||||
const options = within(screen.getByRole("listbox"))
|
||||
.getAllByRole("option")
|
||||
.map((o) => o.textContent);
|
||||
expect(options).toEqual(["All Categories3", "Themes1", "Linters & Formatters1", "Tools1", "Other1"]);
|
||||
await user.click(within(screen.getByRole("listbox")).getByText("Tools"));
|
||||
expect(within(list).queryByText("Dusk")).not.toBeInTheDocument();
|
||||
expect(within(list).queryByText("Local")).not.toBeInTheDocument();
|
||||
await user.click(within(list).getByText("Lint"));
|
||||
expect(screen.getByText("Linters & Formatters, Tools")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a plugin this IDE can't load without installing it", async () => {
|
||||
setPluginsBackend({
|
||||
...createMemoryPluginsBackend([], sampleCatalog()),
|
||||
marketUrl: async () => "https://plugins.example/index.json",
|
||||
market: async () => [
|
||||
{
|
||||
id: "acme.next",
|
||||
name: "Next",
|
||||
version: "2.0.0",
|
||||
description: "",
|
||||
authors: [],
|
||||
publisher: "acme",
|
||||
download: "next.oiplugin",
|
||||
digest: "x",
|
||||
capabilities: [],
|
||||
engine: "^9.0",
|
||||
installed: null,
|
||||
compatible: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<PluginsPage />);
|
||||
await user.click(screen.getByRole("tab", { name: /Available/ }));
|
||||
const list = await screen.findByRole("list", { name: "Available plugins" });
|
||||
await waitFor(() => expect(within(list).getByText("Incompatible")).toBeInTheDocument());
|
||||
await user.click(within(list).getByText("Next"));
|
||||
expect(screen.getByRole("note")).toHaveTextContent("Needs plugin API ^9.0. Update OpenIDEA to install it.");
|
||||
expect(screen.queryByRole("button", { name: "Install" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("updates", () => {
|
||||
const offered = (id: string, version: string, installed: string): MarketPlugin => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
version,
|
||||
description: "",
|
||||
authors: [],
|
||||
publisher: "acme",
|
||||
download: `${id}.oiplugin`,
|
||||
digest: "x",
|
||||
capabilities: [],
|
||||
engine: "^1.0",
|
||||
installed,
|
||||
compatible: true,
|
||||
});
|
||||
const preview = (id: string, version: string, extra: Partial<InstallPreview> = {}): InstallPreview => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
version,
|
||||
description: "",
|
||||
authors: [],
|
||||
hasCode: true,
|
||||
capabilities: [
|
||||
{ id: "editor.read", description: "Read", risk: "low", optional: false, granted: true },
|
||||
{ id: "net:a.example", description: "Net", risk: "medium", optional: true, granted: true },
|
||||
{ id: "net:b.example", description: "Net", risk: "medium", optional: true, granted: false },
|
||||
],
|
||||
contributions: [],
|
||||
warnings: [],
|
||||
replaces: "1.0.0",
|
||||
unsigned: false,
|
||||
publisher: "acme",
|
||||
signerChanged: false,
|
||||
needsConsent: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function updatesBackend(previews: Record<string, InstallPreview>) {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "a", version: "1.0.0" }),
|
||||
plugin({ id: "b", version: "1.0.0" }),
|
||||
]);
|
||||
const consents: [string, Consent][] = [];
|
||||
setPluginsBackend({
|
||||
...base,
|
||||
marketUrl: async () => "https://plugins.example/index.json",
|
||||
market: async () => [
|
||||
offered("a", "1.1.0", "1.0.0"),
|
||||
offered("b", "2.0.0", "1.0.0"),
|
||||
offered("c", "1.0.0", "1.0.0"),
|
||||
],
|
||||
marketDownload: async (id) => id,
|
||||
previewInstall: async (dir) => previews[dir]!,
|
||||
installDir: async (dir, consent) => {
|
||||
consents.push([dir, consent]);
|
||||
return plugin({ id: dir, name: dir.toUpperCase(), version: previews[dir]!.version });
|
||||
},
|
||||
});
|
||||
return consents;
|
||||
}
|
||||
|
||||
it("installs updates with nothing to confirm without asking, keeping their grants", async () => {
|
||||
const asked: ConsentRequest[] = [];
|
||||
setConsentPrompt(async (req) => {
|
||||
asked.push(req);
|
||||
return { optional: [], signerChange: !!req.signerChanged };
|
||||
});
|
||||
const consents = updatesBackend({
|
||||
a: preview("a", "1.1.0"),
|
||||
b: preview("b", "2.0.0", { signerChanged: true, needsConsent: true }),
|
||||
});
|
||||
expect(await updateAllPlugins()).toBe(2);
|
||||
// `a` with the optional capability it had; `b` asked, with the change of signer.
|
||||
expect(asked.map((r) => [r.pluginId, r.signerChanged])).toEqual([["b", true]]);
|
||||
expect(consents).toEqual([
|
||||
["a", { optional: ["net:a.example"] }],
|
||||
["b", { optional: [], signerChange: true }],
|
||||
]);
|
||||
expect(notes.map((n) => n.title)).toEqual(["Updated 2 plugins"]);
|
||||
});
|
||||
|
||||
it("warns about another signer on the install page and confirms it", async () => {
|
||||
const consents = updatesBackend({ b: preview("b", "2.0.0", { signerChanged: true, needsConsent: true }) });
|
||||
const stop = provideConsentDialog();
|
||||
const user = userEvent.setup();
|
||||
render(<ConsentDialogHost />);
|
||||
const done = installFromDisk("b");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("signed with a different key");
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
await done;
|
||||
expect(consents[0]![1]).toMatchObject({ signerChange: true });
|
||||
stop();
|
||||
});
|
||||
|
||||
it("offers Update All in the gear menu while there are updates", async () => {
|
||||
setConsentPrompt(async () => ({ optional: [] }));
|
||||
const consents = updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<>
|
||||
<PluginsPage />
|
||||
<ContextMenuHost />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(usePlugins.getState().market).toHaveLength(3));
|
||||
await user.click(screen.getByRole("button", { name: "Manage Plugins" }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "Update All Plugins (2)" }));
|
||||
await waitFor(() => expect(consents.map(([id]) => id)).toEqual(["a", "b"]));
|
||||
});
|
||||
|
||||
it("announces updates with actions to install them or show the plugins", async () => {
|
||||
updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
|
||||
handlePluginEvent({
|
||||
type: "updatesAvailable",
|
||||
updates: [
|
||||
{ id: "a", name: "A", version: "1.1.0" },
|
||||
{ id: "b", name: "B", version: "2.0.0" },
|
||||
],
|
||||
});
|
||||
const note = notes.at(-1)!;
|
||||
expect(note).toMatchObject({ title: "2 plugin updates available", body: "A 1.1.0, B 2.0.0" });
|
||||
expect(note.actions?.map((a) => a.label)).toEqual(["Update All", "Show Plugins"]);
|
||||
note.actions![0]!.run();
|
||||
await waitFor(() => expect(notes.at(-1)?.title).toBe("Updated 2 plugins"));
|
||||
});
|
||||
});
|
||||
|
||||
it("offers to roll back an update", async () => {
|
||||
const base = createMemoryPluginsBackend([
|
||||
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
|
||||
|
||||
@@ -429,6 +429,8 @@ export function createMemoryPluginsBackend(
|
||||
replaces: plugins.find((p) => p.id === entry.id)?.version ?? null,
|
||||
unsigned: true,
|
||||
publisher: null,
|
||||
signerChanged: false,
|
||||
needsConsent: true,
|
||||
};
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* variables, notifications become balloons, consent requests open the
|
||||
* permission dialog, and opened files fire `on-language` / `on-file`.
|
||||
*/
|
||||
import type { ActiveTheme, PluginEvent } from "@/bindings";
|
||||
import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -27,13 +27,14 @@ import {
|
||||
useThemeOverride,
|
||||
windowBackgroundHex,
|
||||
} from "@/workbench/theme";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { trackIconTheme } from "./iconThemes";
|
||||
import { defineBuiltinUiPoints } from "./points";
|
||||
import { clearUiContributions, syncUiContributions } from "./projection";
|
||||
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, usePlugins } from "./store";
|
||||
import { grantPlugin, refreshPlugins, selectPluginTheme, updateAllPlugins, usePlugins } from "./store";
|
||||
|
||||
export { pluginActionId } from "./points";
|
||||
|
||||
@@ -244,11 +245,28 @@ function trackActiveFile(): () => void {
|
||||
|
||||
// --- events -----------------------------------------------------------------
|
||||
|
||||
function announceUpdates(updates: readonly PluginUpdate[]) {
|
||||
const names = updates.map((u) => `${u.name} ${u.version}`).join(", ");
|
||||
notifications().notify({
|
||||
group: "plugins",
|
||||
level: "info",
|
||||
title: updates.length === 1 ? "Plugin update available" : `${updates.length} plugin updates available`,
|
||||
body: names,
|
||||
actions: [
|
||||
{ label: "Update All", run: () => void updateAllPlugins() },
|
||||
{ label: "Show Plugins", run: () => void executeAction("plugins.settings") },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function handlePluginEvent(e: PluginEvent) {
|
||||
switch (e.type) {
|
||||
case "commandsChanged":
|
||||
scheduleRefresh();
|
||||
break;
|
||||
case "updatesAvailable":
|
||||
announceUpdates(e.updates);
|
||||
break;
|
||||
case "notification": {
|
||||
const name = usePlugins.getState().plugins.find((p) => p.id === e.pluginId)?.name ?? e.pluginId;
|
||||
notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined });
|
||||
|
||||
@@ -59,7 +59,7 @@ function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => vo
|
||||
{
|
||||
label: req.kind === "install" ? tx("Install") : tx("Allow"),
|
||||
role: "default",
|
||||
onClick: () => finish({ optional: [...accepted] }),
|
||||
onClick: () => finish({ optional: [...accepted], signerChange: !!req.signerChanged }),
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -69,6 +69,14 @@ function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => vo
|
||||
<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>
|
||||
|
||||
@@ -33,7 +33,7 @@ import { defineBuiltinUiPoints } from "./points";
|
||||
import { startViewUi } from "./uiPoints";
|
||||
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
|
||||
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
|
||||
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
|
||||
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store";
|
||||
|
||||
function registerPluginsPage(): () => void {
|
||||
return registerSettingsPage({
|
||||
@@ -89,6 +89,16 @@ export function activate(): () => void {
|
||||
await installFromDisk(typeof arg === "string" ? arg : undefined);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plugins.updateAll",
|
||||
title: "Update All Plugins",
|
||||
category: "Plugins",
|
||||
icon: "plugin",
|
||||
keywords: ["upgrade", "marketplace", "new version"],
|
||||
run: async () => {
|
||||
await updateAllPlugins();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plugins.importIntellijTheme",
|
||||
title: "Import IntelliJ Theme from File…",
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface ConsentRequest {
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -209,8 +211,13 @@ export async function setMarketUrl(url: string | null): Promise<void> {
|
||||
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): Promise<string | null> {
|
||||
export async function installFromMarket(id: string, options: InstallOptions = {}): Promise<string | null> {
|
||||
usePlugins.setState({ installing: true });
|
||||
let dir: string;
|
||||
try {
|
||||
@@ -225,11 +232,27 @@ export async function installFromMarket(id: string): Promise<string | null> {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const installed = await installFromDisk(dir);
|
||||
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 {
|
||||
@@ -274,12 +297,22 @@ async function packageFolder(backend: PluginsBackend, picked: string): Promise<s
|
||||
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. Returns the new plugin id.
|
||||
* 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): Promise<string | null> {
|
||||
export async function installFromDisk(
|
||||
path?: string,
|
||||
packageFile = false,
|
||||
options: InstallOptions = {},
|
||||
): Promise<string | null> {
|
||||
const backend = pluginsBackend();
|
||||
let dir: string | null = path ?? null;
|
||||
try {
|
||||
@@ -300,24 +333,29 @@ export async function installFromDisk(path?: string, packageFile = false): Promi
|
||||
let installed: string | null = null;
|
||||
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,
|
||||
});
|
||||
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;
|
||||
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,
|
||||
});
|
||||
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) });
|
||||
|
||||
Reference in new issue
Заблокувати користувача