diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index bffe19c7..aa3fb275 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -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 diff --git a/src-tauri/src/extension/mod.rs b/src-tauri/src/extension/mod.rs index 0c61a3aa..7dce8613 100644 --- a/src-tauri/src/extension/mod.rs +++ b/src-tauri/src/extension/mod.rs @@ -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 diff --git a/src-tauri/src/extension/third_party/install/local_extension.rs b/src-tauri/src/extension/third_party/install/local_extension.rs new file mode 100644 index 00000000..6d675a3f --- /dev/null +++ b/src-tauri/src/extension/third_party/install/local_extension.rs @@ -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( + tauri_app_handle: AppHandle, + 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(()) +} diff --git a/src-tauri/src/extension/third_party/install/mod.rs b/src-tauri/src/extension/third_party/install/mod.rs new file mode 100644 index 00000000..abbe4f64 --- /dev/null +++ b/src-tauri/src/extension/third_party/install/mod.rs @@ -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 +} diff --git a/src-tauri/src/extension/third_party/store.rs b/src-tauri/src/extension/third_party/install/store.rs similarity index 96% rename from src-tauri/src/extension/third_party/store.rs rename to src-tauri/src/extension/third_party/install/store.rs index deaa755e..532bddce 100644 --- a/src-tauri/src/extension/third_party/store.rs +++ b/src-tauri/src/extension/third_party/install/store.rs @@ -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, diff --git a/src-tauri/src/extension/third_party/mod.rs b/src-tauri/src/extension/third_party/mod.rs index 7c3516b7..6280b1e7 100644 --- a/src-tauri/src/extension/third_party/mod.rs +++ b/src-tauri/src/extension/third_party/mod.rs @@ -1,4 +1,4 @@ -pub(crate) mod store; +pub(crate) mod install; use super::Extension; use super::ExtensionType; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c9a594f..4c7aeaab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/Settings/Extensions/index.tsx b/src/components/Settings/Extensions/index.tsx index 2dea502d..0198efac 100644 --- a/src/components/Settings/Extensions/index.tsx +++ b/src/components/Settings/Extensions/index.tsx @@ -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(cloneDeep(INITIAL_STATE)); const { configId, setConfigId } = useExtensionsStore(); + const { addError } = useAppStore(); useEffect(() => { getExtensions(); @@ -160,14 +162,63 @@ export const Extensions = () => { {t("settings.extensions.title")} - + + + + + + + +
{ + platformAdapter.emitEvent("open-extension-store"); + }} + > + {t("settings.extensions.menuItem.extensionStore")} +
+
+ +
{ + 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")} +
+
+
+
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ef27fea2..6f710f4f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -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", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 785efbfe..333e8758 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -204,9 +204,16 @@ "hotkey": "热键", "enabled": "启用状态" }, + "menuItem": { + "extensionStore": "插件商店", + "localExtensionImport": "本地插件导入" + }, "hints": { "addAlias": "添加别名", - "recordHotkey": "录制热键" + "recordHotkey": "录制热键", + "importSuccess": "插件导入成功。", + "importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。", + "extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。" }, "application": { "title": "应用程序",