fix(plugins): install plugin packages from a .zip (#896)

Root cause: Install Plugin from Disk only opened a folder picker and the host could only copy folders, so a zipped plugin could not be installed. The picker now accepts a .zip or a package's plugin.toml, and the host's install_from_path unpacks a zip to a temporary folder and runs it through the existing install_from_dir validation. The package may sit at the archive root or in its only folder (archiver metadata such as __MACOSX is ignored). Archives with unsafe paths or symbolic links, more than 10,000 entries or over 1 GB unpacked are refused, and a zip without plugin.toml (e.g. an IntelliJ plugin) gets a clear error.

Fixes #892

Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
Florent Vтаflorentvdry authored and GitHub committed 2026-11-03 01:44:28 +08:00
1 джерело eb633f6ca6
коміт db5251efe6
12 files changed
+208 -21

No files matched your search

+10 -4
Переглянути файл
@@ -5,6 +5,7 @@
*/
import { Channel } from "@tauri-apps/api/core";
import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
export interface PluginsBackend {
@@ -21,7 +22,8 @@ export interface PluginsBackend {
/** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */
setThemeFrame(color: string | null): Promise<void>;
/** Folder picker; `null` when cancelled. */
pickFolder(): Promise<string | null>;
/** A package to install: a `.zip`, or the `plugin.toml` of a folder. */
pickPackage(): Promise<string | null>;
/** Subscribes to backend plugin events (this window only). */
onEvent(handler: (e: PluginEvent) => void): () => void;
}
@@ -45,9 +47,13 @@ export const tauriPluginsBackend: PluginsBackend = {
setThemeFrame: async (color) => {
await call(commands.pluginSetThemeFrame(color));
},
pickFolder: async () => {
pickPackage: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
const picked = await open({
multiple: false,
title: tx("Install Plugin from Disk"),
filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }],
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
onEvent: (handler) => {
@@ -247,7 +253,7 @@ export function createMemoryPluginsBackend(
return t;
},
setThemeFrame: async () => {},
pickFolder: async () => null,
pickPackage: async () => null,
onEvent: (handler) => {
listeners.add(handler);
return () => void listeners.delete(handler);
+6 -6
Переглянути файл
@@ -124,25 +124,25 @@ export function compareVersions(a: string, b: string): number {
return 0;
}
/** Asks for a plugin folder (containing plugin.toml) and installs it. Returns the new plugin id. */
/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */
export async function installFromDisk(path?: string): Promise<string | null> {
const backend = pluginsBackend();
let dir: string | null = path ?? null;
let source: string | null = path ?? null;
try {
dir ??= await backend.pickFolder();
source ??= await backend.pickPackage();
} catch (e) {
notifications().notify({
group: "plugins",
level: "error",
title: "Could not open the folder picker",
title: "Could not open the file picker",
body: errorText(e),
});
return null;
}
if (!dir) return null;
if (!source) return null;
usePlugins.setState({ installing: true });
try {
const info = await backend.installDir(dir);
const info = await backend.installDir(source);
notifications().notify({
group: "plugins",
level: info.status === "failed" ? "warning" : "info",