mirror of
https://github.com/infinilabs/coco-app.git
synced 2026-09-01 19:51:52 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
207
src-tauri/src/extension/third_party/install/local_extension.rs
vendored
Normal file
207
src-tauri/src/extension/third_party/install/local_extension.rs
vendored
Normal 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(())
|
||||
}
|
||||
37
src-tauri/src/extension/third_party/install/mod.rs
vendored
Normal file
37
src-tauri/src/extension/third_party/install/mod.rs
vendored
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
2
src-tauri/src/extension/third_party/mod.rs
vendored
2
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -1,4 +1,4 @@
|
||||
pub(crate) mod store;
|
||||
pub(crate) mod install;
|
||||
|
||||
use super::Extension;
|
||||
use super::ExtensionType;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -204,9 +204,16 @@
|
||||
"hotkey": "热键",
|
||||
"enabled": "启用状态"
|
||||
},
|
||||
"menuItem": {
|
||||
"extensionStore": "插件商店",
|
||||
"localExtensionImport": "本地插件导入"
|
||||
},
|
||||
"hints": {
|
||||
"addAlias": "添加别名",
|
||||
"recordHotkey": "录制热键"
|
||||
"recordHotkey": "录制热键",
|
||||
"importSuccess": "插件导入成功。",
|
||||
"importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。",
|
||||
"extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。"
|
||||
},
|
||||
"application": {
|
||||
"title": "应用程序",
|
||||
|
||||
Reference in New Issue
Block a user