feat: support installing local extensions (#749)

This commit adds support for installing extensions from a local folder path:

```text
extension-directory/
├── assets/
│   ├── icon.png
│   └── other-assets...
└── plugin.json
```

Useful for testing and development of extensions before publishing.

Co-authored-by: ayang <473033518@qq.com>
This commit is contained in:
SteveLauC
2025-07-29 10:26:47 +08:00
committed by GitHub
parent 6bc78b41ef
commit 32d4f45144
10 changed files with 330 additions and 28 deletions

View File

@@ -14,6 +14,7 @@ Information about release notes of Coco App is provided here.
### 🚀 Features
- feat: enhance ui for skipped version #834
- feat: support installing local extensions #749
### 🐛 Bug fix

View File

@@ -498,7 +498,7 @@ pub(crate) async fn init_extensions(
// extension store
search_source_registry_tauri_state
.register_source(third_party::store::ExtensionStore)
.register_source(third_party::install::store::ExtensionStore)
.await;
// Init the built-in enabled extensions

View File

@@ -0,0 +1,207 @@
use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::third_party::install::is_extension_installed;
use crate::extension::third_party::{
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
};
use crate::extension::{Extension, canonicalize_relative_icon_path};
use serde_json::Value as Json;
use std::path::Path;
use std::path::PathBuf;
use tauri::{AppHandle, Runtime};
use tokio::fs;
/// All the extensions installed from local file will belong to a special developer
/// "__local__".
const DEVELOPER_ID_LOCAL: &str = "__local__";
/// Install the extension specified by `path`.
///
/// `path` should point to a directory with the following structure:
///
/// ```text
/// extension-directory/
/// ├── assets/
/// │ ├── icon.png
/// │ └── other-assets...
/// └── plugin.json
/// ```
#[tauri::command]
pub(crate) async fn install_local_extension<R: Runtime>(
tauri_app_handle: AppHandle<R>,
path: PathBuf,
) -> Result<(), String> {
let extension_dir_name = path
.file_name()
.ok_or_else(|| "Invalid extension: no directory name".to_string())?
.to_str()
.ok_or_else(|| "Invalid extension: non-UTF8 extension id".to_string())?;
// we use extension directory name as the extension ID.
let extension_id = extension_dir_name;
if is_extension_installed(DEVELOPER_ID_LOCAL, extension_id).await {
// The frontend code uses this string to distinguish between 2 error cases:
//
// 1. This extension is already imported
// 2. The selected directory does not contain a valid extension
//
// do NOT edit this without updating the frontend code.
//
// ```ts
// if (errorMessage === "already imported") {
// addError(t("extensionAlreadyImported"));
// } else {
// addError(t("settings.extensions.hints.importFailed"));
// }
// ```
//
// This is definitely error-prone, but we have to do this until we have
// structured error type
return Err("already imported".into());
}
let plugin_json_path = path.join(PLUGIN_JSON_FILE_NAME);
let plugin_json_content = fs::read_to_string(&plugin_json_path)
.await
.map_err(|e| e.to_string())?;
// Parse as JSON first as it is not valid for `struct Extension`, we need to
// correct it (set fields `id` and `developer`) before converting it to `struct Extension`:
let mut extension_json: Json =
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
// Set the main extension ID to the directory name
let extension_obj = extension_json
.as_object_mut()
.expect("extension_json should be an object");
extension_obj.insert("id".to_string(), Json::String(extension_id.to_string()));
extension_obj.insert(
"developer".to_string(),
Json::String(DEVELOPER_ID_LOCAL.to_string()),
);
// Counter for sub-extension IDs
let mut counter = 1u32;
// Set IDs for commands
if let Some(commands) = extension_obj.get_mut("commands") {
if let Some(commands_array) = commands.as_array_mut() {
for command in commands_array {
if let Some(command_obj) = command.as_object_mut() {
command_obj.insert("id".to_string(), Json::String(counter.to_string()));
counter += 1;
}
}
}
}
// Set IDs for quicklinks
if let Some(quicklinks) = extension_obj.get_mut("quicklinks") {
if let Some(quicklinks_array) = quicklinks.as_array_mut() {
for quicklink in quicklinks_array {
if let Some(quicklink_obj) = quicklink.as_object_mut() {
quicklink_obj.insert("id".to_string(), Json::String(counter.to_string()));
counter += 1;
}
}
}
}
// Set IDs for scripts
if let Some(scripts) = extension_obj.get_mut("scripts") {
if let Some(scripts_array) = scripts.as_array_mut() {
for script in scripts_array {
if let Some(script_obj) = script.as_object_mut() {
script_obj.insert("id".to_string(), Json::String(counter.to_string()));
counter += 1;
}
}
}
}
// Now we can convert JSON to `struct Extension`
let mut extension: Extension =
serde_json::from_value(extension_json).map_err(|e| e.to_string())?;
// Create destination directory
let dest_dir = get_third_party_extension_directory(&tauri_app_handle)
.join(DEVELOPER_ID_LOCAL)
.join(extension_dir_name);
fs::create_dir_all(&dest_dir)
.await
.map_err(|e| e.to_string())?;
// Copy all files except plugin.json
let mut entries = fs::read_dir(&path).await.map_err(|e| e.to_string())?;
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
let file_name = entry.file_name();
let file_name_str = file_name
.to_str()
.ok_or_else(|| "Invalid filename: non-UTF8".to_string())?;
// plugin.json will be handled separately.
if file_name_str == PLUGIN_JSON_FILE_NAME {
continue;
}
let src_path = entry.path();
let dest_path = dest_dir.join(&file_name);
if src_path.is_dir() {
// Recursively copy directory
copy_dir_recursively(&src_path, &dest_path).await?;
} else {
// Copy file
fs::copy(&src_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
}
}
// Write the corrected plugin.json file
let corrected_plugin_json =
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
let dest_plugin_json_path = dest_dir.join(PLUGIN_JSON_FILE_NAME);
fs::write(&dest_plugin_json_path, corrected_plugin_json)
.await
.map_err(|e| e.to_string())?;
// Canonicalize relative icon paths
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
// Add extension to the search source
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.add_extension(extension)
.await;
Ok(())
}
/// Helper function to recursively copy directories.
#[async_recursion::async_recursion]
async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), String> {
tokio::fs::create_dir_all(dest)
.await
.map_err(|e| e.to_string())?;
let mut read_dir = tokio::fs::read_dir(src).await.map_err(|e| e.to_string())?;
while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursively(&src_path, &dest_path).await?;
} else {
tokio::fs::copy(&src_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
}
}
Ok(())
}

View File

@@ -0,0 +1,37 @@
//! This module contains the code of extension installation.
//!
//!
//! # How
//!
//! Technically, installing an extension involves the following steps:
//!
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
//! definition.
//!
//! 2. Write the extension files to the corresponding location
//!
//! * developer directory
//! * extension directory
//! * assets directory
//! * various assets files, e.g., "icon.png"
//! * plugin.json file
//!
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
//! (relative to the `assets` directory)
//!
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
//! the in-memory extension list.
pub(crate) mod local_extension;
pub(crate) mod store;
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
pub(crate) async fn is_extension_installed(developer: &str, extension_id: &str) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.extension_exists(developer, extension_id)
.await
}

View File

@@ -1,6 +1,7 @@
//! Extension store related stuff.
use super::LOCAL_QUERY_SOURCE_TYPE;
use super::super::LOCAL_QUERY_SOURCE_TYPE;
use super::is_extension_installed;
use crate::common::document::DataSourceReference;
use crate::common::document::Document;
use crate::common::error::SearchError;
@@ -152,14 +153,12 @@ pub(crate) async fn search_extension(
.get("developer")
.and_then(|dev| dev.get("id"))
.and_then(|id| id.as_str())
.expect("developer.id should exist")
.to_string();
.expect("developer.id should exist");
let extension_id = source_obj
.get("id")
.and_then(|id| id.as_str())
.expect("extension id should exist")
.to_string();
.expect("extension id should exist");
let installed = is_extension_installed(developer_id, extension_id).await;
source_obj.insert("installed".to_string(), Json::Bool(installed));
@@ -170,14 +169,6 @@ pub(crate) async fn search_extension(
Ok(extensions)
}
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
.get()
.unwrap()
.extension_exists(&developer, &extension_id)
.await
}
#[tauri::command]
pub(crate) async fn install_extension_from_store(
tauri_app_handle: AppHandle,

View File

@@ -1,4 +1,4 @@
pub(crate) mod store;
pub(crate) mod install;
use super::Extension;
use super::ExtensionType;

View File

@@ -166,8 +166,9 @@ pub fn run() {
extension::register_extension_hotkey,
extension::unregister_extension_hotkey,
extension::is_extension_enabled,
extension::third_party::store::search_extension,
extension::third_party::store::install_extension_from_store,
extension::third_party::install::store::search_extension,
extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension,
settings::set_allow_self_signature,
settings::get_allow_self_signature,

View File

@@ -5,13 +5,14 @@ import type { LiteralUnion } from "type-fest";
import { cloneDeep, sortBy } from "lodash-es";
import clsx from "clsx";
import { Plus } from "lucide-react";
import { Button } from "@headlessui/react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import platformAdapter from "@/utils/platformAdapter";
import Content from "./components/Content";
import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore";
import SettingsInput from "../SettingsInput";
import { useAppStore } from "@/stores/appStore";
export type ExtensionId = LiteralUnion<
| "Applications"
@@ -90,6 +91,7 @@ export const Extensions = () => {
const { t } = useTranslation();
const state = useReactive<State>(cloneDeep(INITIAL_STATE));
const { configId, setConfigId } = useExtensionsStore();
const { addError } = useAppStore();
useEffect(() => {
getExtensions();
@@ -160,14 +162,63 @@ export const Extensions = () => {
{t("settings.extensions.title")}
</h2>
<Button
className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
<Plus className="size-4 text-[#0096FB]" />
</Button>
<Menu>
<MenuButton className="flex items-center justify-center size-6 border rounded-md dark:border-gray-700 hover:!border-[#0096FB] transition">
<Plus className="size-4 text-[#0096FB]" />
</MenuButton>
<MenuItems
anchor={{ gap: 4 }}
className="p-1 text-sm bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700"
>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={() => {
platformAdapter.emitEvent("open-extension-store");
}}
>
{t("settings.extensions.menuItem.extensionStore")}
</div>
</MenuItem>
<MenuItem>
<div
className="px-3 py-2 hover:bg-black/5 hover:dark:bg-white/5 rounded-lg cursor-pointer"
onClick={async () => {
try {
const path = await platformAdapter.openFileDialog({
directory: true,
});
if (!path) return;
await platformAdapter.invokeBackend(
"install_local_extension",
{ path }
);
await getExtensions();
addError(
t("settings.extensions.hints.importSuccess"),
"info"
);
} catch (error) {
const errorMessage = String(error);
if (errorMessage === "already imported") {
addError(t("settings.extensions.hints.extensionAlreadyImported"));
} else {
addError(t("settings.extensions.hints.importFailed"));
}
}
}}
>
{t("settings.extensions.menuItem.localExtensionImport")}
</div>
</MenuItem>
</MenuItems>
</Menu>
</div>
<div className="flex justify-between gap-6 my-4">

View File

@@ -204,9 +204,16 @@
"hotkey": "Hotkey",
"enabled": "Enabled"
},
"menuItem": {
"extensionStore": "Extension Store",
"localExtensionImport": "Import Local Extension"
},
"hints": {
"addAlias": "Add Alias",
"recordHotkey": "Record Hotkey"
"recordHotkey": "Record Hotkey",
"importSuccess": "Extension imported successfully.",
"importFailed": "No valid extension found in the selected folder. Please check the folder structure.",
"extensionAlreadyImported": "Extension already imported. Please remove it first."
},
"application": {
"title": "Applications",

View File

@@ -204,9 +204,16 @@
"hotkey": "热键",
"enabled": "启用状态"
},
"menuItem": {
"extensionStore": "插件商店",
"localExtensionImport": "本地插件导入"
},
"hints": {
"addAlias": "添加别名",
"recordHotkey": "录制热键"
"recordHotkey": "录制热键",
"importSuccess": "插件导入成功。",
"importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。",
"extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。"
},
"application": {
"title": "应用程序",