feat(plugins): install packages from a .zip or a folder's plugin.toml

Install Plugin from Package File now takes an `.oiplugin`, a `.zip` or a
package folder's `plugin.toml`, and goes through the same install page
(capabilities, signature, consent) as every other install.

- A zip may hold the package at its root or in its only folder, as zipping
  the folder makes; archiver metadata (`__MACOSX`, `.DS_Store`, `._*`) is
  left out. A zip without a `plugin.toml` there, such as an IntelliJ plugin,
  is refused with a message saying so. Unsafe paths, links and size limits
  are checked as for `.oiplugin` files.
- Picking a `plugin.toml` installs its folder.

Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
l0ng-aiтаflorentvdry committed 2026-11-04 02:41:46 +08:00
1 джерело 900f2e6819
коміт a7b7616da9
11 files changed
+200 -21

No files matched your search

Створено
+1
Переглянути файл
@@ -4280,6 +4280,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
"zip",
]
[[package]]
+92 -5
Переглянути файл
@@ -141,7 +141,31 @@ fn safe_path(name: &str) -> Option<PathBuf> {
(!name.is_empty() && p.components().all(|c| matches!(c, Component::Normal(_)))).then(|| p.to_path_buf())
}
/// Extracts the package `archive` into the empty directory `dest`, refusing
/// Archiver metadata at the top of an archive, not part of a package
/// (`__MACOSX/…`, `.DS_Store`, AppleDouble `._…` files).
fn archiver_metadata(name: &str) -> bool {
let top = name.split('/').next().unwrap_or_default();
top == "__MACOSX" || top == ".DS_Store" || (top.starts_with("._") && top.len() > 2)
}
/// Where the package is inside an archive: at its root (`.oiplugin`, or a zip
/// of the package's contents) or in its only folder (a zip of the package
/// folder). `None` when neither holds a manifest.
fn package_prefix(names: &[String]) -> Option<String> {
if names.iter().any(|n| n == MANIFEST_FILE) {
return Some(String::new());
}
let mut tops = names.iter().filter(|n| !archiver_metadata(n)).map(|n| n.split('/').next().unwrap_or_default());
let top = tops.next()?;
if tops.any(|t| t != top) {
return None;
}
let prefix = format!("{top}/");
names.iter().any(|n| *n == format!("{prefix}{MANIFEST_FILE}")).then_some(prefix)
}
/// Extracts the package `archive` (an `.oiplugin`, or a `.zip` of a package
/// folder or of its contents) into the empty directory `dest`, refusing
/// paths leaving it, links, and oversized contents.
pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
let file = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
@@ -150,6 +174,17 @@ pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
if zip.len() > MAX_ENTRIES {
return Err(format!("more than {MAX_ENTRIES} files"));
}
let mut names = Vec::with_capacity(zip.len());
for i in 0..zip.len() {
let entry = zip.by_index(i).map_err(|e| e.to_string())?;
if !entry.is_dir() {
names.push(entry.name().map_err(|e| e.to_string())?.into_owned());
}
}
// An IntelliJ plugin or any other zip: say what is missing rather than fail later.
let prefix = package_prefix(&names).ok_or_else(|| {
format!("{} is not an OpenIDEA plugin: no {MANIFEST_FILE} at its root or in its only folder", archive.display())
})?;
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let (mut files, mut signature, mut total, mut manifest) = (Vec::new(), None, 0u64, false);
for i in 0..zip.len() {
@@ -157,11 +192,16 @@ pub fn unpack(archive: &Path, dest: &Path) -> Result<Unpacked, String> {
if entry.is_dir() {
continue;
}
let name = entry.name().map_err(|e| e.to_string())?.into_owned();
if entry.is_symlink() {
return Err(format!("{name}: links are not allowed in packages"));
let full = entry.name().map_err(|e| e.to_string())?.into_owned();
// Outside the package folder there is only archiver metadata (`package_prefix` checked).
let Some(name) = full.strip_prefix(&prefix).map(str::to_owned) else { continue };
if archiver_metadata(&name) {
continue;
}
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {name:?} in package"))?;
if entry.is_symlink() {
return Err(format!("{full}: links are not allowed in packages"));
}
let rel = safe_path(&name).ok_or_else(|| format!("invalid path {full:?} in package"))?;
let mut bytes = Vec::new();
entry.by_ref().take(MAX_TOTAL - total + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
total += bytes.len() as u64;
@@ -375,4 +415,51 @@ mod tests {
assert!(!tmp.path().join("outside.txt").exists());
assert!(Index::parse(r#"{"format": 2}"#).is_err());
}
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
let mut zip = zip::ZipWriter::new(std::fs::File::create(path).unwrap());
for (name, body) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(body.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
#[test]
fn a_zip_holds_the_package_at_its_root_or_in_its_only_folder() {
let tmp = tempfile::tempdir().unwrap();
let manifest = "id = \"acme.tools\"";
let at_root = tmp.path().join("root.zip");
zip_of(&at_root, &[(MANIFEST_FILE, manifest), ("dist/plugin.wasm", "w"), (".DS_Store", "x")]);
let got = unpack(&at_root, &tmp.path().join("a")).unwrap();
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
// What zipping the folder in Finder makes: the folder plus `__MACOSX`.
let folder = tmp.path().join("folder.zip");
zip_of(
&folder,
&[
("acme-tools/plugin.toml", manifest),
("acme-tools/dist/plugin.wasm", "w"),
("__MACOSX/acme-tools/._plugin.toml", "x"),
],
);
let dest = tmp.path().join("b");
let got = unpack(&folder, &dest).unwrap();
assert_eq!(got.files, [PathBuf::from("dist/plugin.wasm")]);
assert!(dest.join(MANIFEST_FILE).is_file());
assert!(!dest.join("__MACOSX").exists() && !dest.join("acme-tools").exists());
// Two folders, a manifest one level too deep, or none (an IntelliJ plugin): not a package.
for (name, entries) in [
("two.zip", &[("a/plugin.toml", manifest), ("b/x.txt", "x")][..]),
("deep.zip", &[("a/b/plugin.toml", manifest)][..]),
("intellij.zip", &[("my-plugin/lib/my-plugin.jar", "jar")][..]),
] {
let path = tmp.path().join(name);
zip_of(&path, entries);
let err = unpack(&path, &tmp.path().join(name).with_extension("out")).unwrap_err();
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
}
}
}
+1
Переглянути файл
@@ -62,6 +62,7 @@ tempfile.workspace = true
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
criterion = { version = "0.5", default-features = false }
zip.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
+1 -1
Переглянути файл
@@ -152,7 +152,7 @@ impl PluginHost {
Ok(dir)
}
/// Unpacks a package file (`.oiplugin`) next to the downloads; returns the folder.
/// Unpacks a package file (`.oiplugin`, or a `.zip` of a package) next to the downloads; returns the folder.
pub fn unpack_package(&self, archive: &Path) -> IdeResult<PathBuf> {
let stem = archive.file_stem().and_then(|s| s.to_str()).unwrap_or("package");
let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{stem}-{}", std::process::id()));
+47
Переглянути файл
@@ -2116,3 +2116,50 @@ fn official_excalidraw_installs_and_serves_its_page() {
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw"));
assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2);
}
/// Zips `dir` as an archiver does: the folder itself at the top, plus macOS metadata.
fn zip_folder(dir: &Path, out: &Path) {
use std::io::Write;
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap());
let options = zip::write::SimpleFileOptions::default();
let mut stack = vec![(dir.to_path_buf(), name.clone())];
while let Some((real, rel)) = stack.pop() {
for entry in std::fs::read_dir(&real).unwrap().flatten() {
let (path, rel) = (entry.path(), format!("{rel}/{}", entry.file_name().to_string_lossy()));
if path.is_dir() {
stack.push((path, rel));
} else {
zip.start_file(rel, options).unwrap();
zip.write_all(&std::fs::read(&path).unwrap()).unwrap();
}
}
}
zip.start_file(format!("__MACOSX/{name}/._plugin.toml"), options).unwrap();
zip.write_all(b"metadata").unwrap();
zip.finish().unwrap();
}
#[test]
fn a_zipped_plugin_folder_installs_like_the_folder() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let archive = tmp.path().join("language-toy.zip");
zip_folder(&sample("language-toy"), &archive);
let dir = f.host.unpack_package(&archive).unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!(preview.id, "language-toy");
assert!(preview.unsigned);
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.id, "language-toy");
assert_eq!(f.host.languages().len(), 1);
// A zip that isn't an OpenIDEA plugin says so.
let other = tmp.path().join("intellij-plugin.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&other).unwrap());
zip.start_file("my-plugin/lib/my-plugin.jar", zip::write::SimpleFileOptions::default()).unwrap();
zip.finish().unwrap();
let err = f.host.unpack_package(&other).unwrap_err().message;
assert!(err.contains("is not an OpenIDEA plugin"), "{err}");
}
+3 -2
Переглянути файл
@@ -109,8 +109,9 @@ There is no build step. The package *is* the folder.
1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files.
2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds).
3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the
*Install Plugin from Disk…* action) and pick the package's `plugin.toml`, or
a `.zip` of the folder. The manifest and the files it references are copied to
*Install Plugin from Disk…* action) and pick the package folder, or
*Install Plugin from Package File…* and pick its `plugin.toml` or a `.zip` of
the folder. The manifest and the files it references are copied to
`<app-data>/plugins/<id>/`.
4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next
to the theme. *Use Default Theme* switches back.
+3 -1
Переглянути файл
@@ -636,7 +636,9 @@ granted, and starts the approved program.
## 7. Installing and developing
- **Install:** Settings › Plugins › _Install Plugin from Disk…_ (a package
folder) or _Install Plugin from Package File…_ (a `.oiplugin`). The install
folder) or _Install Plugin from Package File…_ (a `.oiplugin`, a `.zip` with
the package at its root or in its only folder, or a folder's `plugin.toml`;
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
+1 -1
Переглянути файл
@@ -498,7 +498,7 @@ pub async fn plugin_market_download(state: State<'_, AppState>, id: String) -> I
blocking(move || host.download_market_plugin(&id)).await
}
/// Unpacks a package file (`.oiplugin`); returns the folder for the install page.
/// Unpacks a package file (`.oiplugin` or `.zip`); returns the folder for the install page.
#[tauri::command]
#[specta::specta]
pub async fn plugin_unpack_package(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PathBuf> {
+30 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorSelection, EditorState } from "@codemirror/state";
@@ -272,6 +272,35 @@ describe("store", () => {
expect(usePlugins.getState().activeTheme).toBeNull();
});
it("installs from a folder, its plugin.toml, or a package file unpacked first", async () => {
setConsentPrompt(async () => ({ optional: [] }));
for (const [picked, unpacked] of [
["plugins/hello", null],
["plugins/hello/plugin.toml", null],
["C:\\plugins\\hello\\Plugin.TOML", null],
["/downloads/hello.zip", "/downloads/hello.zip"],
["/downloads/hello-1.0.0.oiplugin", "/downloads/hello-1.0.0.oiplugin"],
] as const) {
const unpack = vi.fn(async () => "plugins/hello");
const preview = vi.fn();
const backend = createMemoryPluginsBackend([], sampleCatalog());
setPluginsBackend({
...backend,
unpackPackage: unpack,
previewInstall: (path) => {
preview(path);
return backend.previewInstall("plugins/hello");
},
installDir: (_path, consent) => backend.installDir("plugins/hello", consent),
});
expect(await installFromDisk(picked)).toBe("hello");
expect(unpack.mock.calls).toEqual(unpacked ? [[unpacked]] : []);
const folder = unpacked ? "plugins/hello" : picked.replace(/[\\/]plugin\.toml$/i, "");
expect(preview).toHaveBeenCalledWith(folder);
usePlugins.setState({ plugins: [] });
}
});
it("asks for consent before installing and passes the answer on", async () => {
const asked: ConsentRequest[] = [];
setConsentPrompt(async (req) => {
+5 -4
Переглянути файл
@@ -24,6 +24,7 @@ import type {
Viewport,
WorkbenchEvent,
} from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
export interface PluginsBackend {
@@ -63,11 +64,11 @@ export interface PluginsBackend {
setMarketUrl(url: string | null): Promise<void>;
/** Downloads and checks a marketplace plugin; the folder to install from. */
marketDownload(id: string): Promise<string>;
/** Unpacks a `.oiplugin` package file; the folder to install from. */
/** Unpacks a package file (`.oiplugin` or `.zip`); the folder to install from. */
unpackPackage(path: string): Promise<string>;
/** Goes back to the version the last update replaced. */
rollBack(id: string): Promise<PluginInfo>;
/** File picker for a package file; `null` when cancelled. */
/** File picker for a package: an `.oiplugin`, a `.zip` or a folder's `plugin.toml`; `null` when cancelled. */
pickPackage(): Promise<string | null>;
/** Content of a plugin view (`doc`: the document of a custom editor). */
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
@@ -159,8 +160,8 @@ export const tauriPluginsBackend: PluginsBackend = {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
multiple: false,
title: "Install Plugin from Package File",
filters: [{ name: "OpenIDEA plugin", extensions: ["oiplugin"] }],
title: tx("Install Plugin from Package File"),
filters: [{ name: tx("Plugin package"), extensions: ["oiplugin", "zip", "toml"] }],
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
+16 -6
Переглянути файл
@@ -13,7 +13,7 @@ import type {
} from "@/bindings";
import { activeEditor } from "@/editor/api";
import { notifications } from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
import { pluginsBackend, type PluginsBackend } from "./backend";
export const SETTINGS_PAGE_ID = "plugins";
@@ -256,22 +256,32 @@ export function compareVersions(a: string, b: string): number {
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;
}
/**
* 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.
* 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.
*/
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);
// 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 folder picker",
title: packageFile ? "Could not open the package" : "Could not open the file picker",
body: errorText(e),
});
return null;