From bc524e19db84fab26e9f0c1c3e9b88260ee19cd4 Mon Sep 17 00:00:00 2001 From: SteveLauC Date: Wed, 9 Jul 2025 16:28:59 +0800 Subject: [PATCH] refactor: adjust extension code hierarchy (#747) * refactor: adjust extension code hierarchy In this commit, I refactored the extension code structure. * We can only install third-party extensions so the `store.rs` file should belong to the `third_party` directory. * Move tauri command `uninstall_extension()` to `extension/mod.rs` from `third_party.rs` since one can uninstall an extension regardless of how you installed it. * Refactor the `install_extension_from_store()` function, add more descriptive code comments. Also, a trivial change, bump Rust toolchain and edition to use the [let-chains](https://blog.rust-lang.org/2025/06/26/Rust-1.88.0/#let-chains) syntax. * chore: release notes --- docs/content.en/docs/release-notes/_index.md | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/rust-toolchain.toml | 2 +- src-tauri/src/extension/mod.rs | 14 ++- .../{third_party.rs => third_party/mod.rs} | 50 ++++++--- .../src/extension/{ => third_party}/store.rs | 105 +++++++----------- src-tauri/src/lib.rs | 13 ++- src/components/Search/ExtensionStore.tsx | 2 +- 8 files changed, 97 insertions(+), 92 deletions(-) rename src-tauri/src/extension/{third_party.rs => third_party/mod.rs} (97%) rename src-tauri/src/extension/{ => third_party}/store.rs (81%) diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index 26f0d2a9..f8400641 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -27,6 +27,7 @@ Information about release notes of Coco Server is provided here. - refactor: create chat & send chat api #739 - chore: icon support for more file types #740 - chore: replace meval-rs with our fork to clear dep warning #745 +- refactor: adjust extension code hierarchy #747 ## 0.6.0 (2025-06-29) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6ed43fb1..08bbd92d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "coco" version = "0.6.0" description = "Search, connect, collaborate – all in one place." authors = ["INFINI Labs"] -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] diff --git a/src-tauri/rust-toolchain.toml b/src-tauri/rust-toolchain.toml index 2e552f81..27b9f8aa 100644 --- a/src-tauri/rust-toolchain.toml +++ b/src-tauri/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "nightly-2025-06-06" \ No newline at end of file +channel = "nightly-2025-06-26" \ No newline at end of file diff --git a/src-tauri/src/extension/mod.rs b/src-tauri/src/extension/mod.rs index 7c17dea3..fb7010b7 100644 --- a/src-tauri/src/extension/mod.rs +++ b/src-tauri/src/extension/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod built_in; -pub(crate) mod store; -mod third_party; +pub(crate) mod third_party; use crate::common::document::OnOpened; use crate::{common::register::SearchSourceRegistry, GLOBAL_TAURI_APP_HANDLE}; @@ -56,7 +55,9 @@ pub struct Extension { platforms: Option>, /// Extension description. description: String, - //// Specify the icon for this extension, multi options are available: + //// Specify the icon for this extension, + /// + /// For the `plugin.json` file, this field can be specified in multi options: /// /// 1. It can be a path to the icon file, the path can be /// @@ -68,6 +69,11 @@ pub struct Extension { /// In cases where your icon file is named similarly to a font class code, Coco /// will treat it as an icon file if it exists, i.e., if file `/assets/font_coco` /// exists, then Coco will use this file rather than the built-in 'font_coco' icon. + /// + /// For the `struct Extension` loaded into memory, this field should be: + /// + /// 1. An absolute path + /// 2. A font code icon: String, r#type: ExtensionType, /// If this is a Command extension, then action defines the operation to execute @@ -501,7 +507,7 @@ pub(crate) async fn init_extensions(mut extensions: Vec) -> Result<() // extension store search_source_registry_tauri_state - .register_source(store::ExtensionStore) + .register_source(third_party::store::ExtensionStore) .await; // Init the built-in enabled extensions diff --git a/src-tauri/src/extension/third_party.rs b/src-tauri/src/extension/third_party/mod.rs similarity index 97% rename from src-tauri/src/extension/third_party.rs rename to src-tauri/src/extension/third_party/mod.rs index 5e9fbe25..bf720331 100644 --- a/src-tauri/src/extension/third_party.rs +++ b/src-tauri/src/extension/third_party/mod.rs @@ -1,3 +1,5 @@ +pub(crate) mod store; + use super::alter_extension_json_file; use super::canonicalize_relative_icon_path; use super::Extension; @@ -84,18 +86,6 @@ pub(crate) async fn list_third_party_extensions( continue 'developer; } - let Ok(developer) = developer_dir.file_name().into_string() else { - found_invalid_extensions = true; - - log::warn!( - "developer [{}] ID is not UTF-8 encoded", - developer_dir.file_name().display() - ); - - // Skip this file - continue 'developer; - }; - let mut developer_dir_iter = read_dir(&developer_dir.path()) .await .map_err(|e| e.to_string())?; @@ -172,9 +162,6 @@ pub(crate) async fn list_third_party_extensions( continue; } - // Set extension's developer info manually. - extension.developer = Some(developer.clone()); - extensions.push(extension); } } @@ -805,6 +792,7 @@ impl ThirdPartyExtensionsSearchSource { .any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id) } + /// Add `extension` to the **in-memory** extension list. pub(crate) async fn add_extension(&self, extension: Extension) { assert!( extension.developer.is_some(), @@ -828,6 +816,7 @@ impl ThirdPartyExtensionsSearchSource { write_lock_guard.push(extension); } + /// Remove `extension` from the **in-memory** extension list. pub(crate) async fn remove_extension(&self, developer: &str, extension_id: &str) { let mut write_lock_guard = self.inner.extensions.write().await; let Some(index) = write_lock_guard @@ -1062,6 +1051,37 @@ fn calculate_text_similarity(query: &str, text: &str) -> Option { } } + +#[tauri::command] +pub(crate) async fn uninstall_extension( + developer: String, + extension_id: String, +) -> Result<(), String> { + let extension_dir = { + let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.join(developer.as_str()); + path.push(extension_id.as_str()); + + path + }; + if !extension_dir.try_exists().map_err(|e| e.to_string())? { + panic!( + "we are uninstalling extension [{}/{}], but there is no such extension files on disk", + developer, extension_id + ) + } + tokio::fs::remove_dir_all(extension_dir.as_path()) + .await + .map_err(|e| e.to_string())?; + + THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE + .get() + .unwrap() + .remove_extension(&developer, &extension_id) + .await; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/extension/store.rs b/src-tauri/src/extension/third_party/store.rs similarity index 81% rename from src-tauri/src/extension/store.rs rename to src-tauri/src/extension/third_party/store.rs index ddf27723..057ea55f 100644 --- a/src-tauri/src/extension/store.rs +++ b/src-tauri/src/extension/third_party/store.rs @@ -18,6 +18,7 @@ use async_trait::async_trait; use reqwest::StatusCode; use serde_json::Map as JsonObject; use serde_json::Value as Json; +use std::io::Read; const DATA_SOURCE_ID: &str = "Extension Store"; @@ -173,7 +174,7 @@ async fn is_extension_installed(developer: String, extension_id: String) -> bool } #[tauri::command] -pub(crate) async fn install_extension(id: String) -> Result<(), String> { +pub(crate) async fn install_extension_from_store(id: String) -> Result<(), String> { let path = format!("store/extension/{}/_download", id); let response = HttpClient::get("default_coco_server", &path, None) .await @@ -192,7 +193,13 @@ pub(crate) async fn install_extension(id: String) -> Result<(), String> { let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; - let mut plugin_json = archive.by_name("plugin.json").map_err(|e| e.to_string())?; + // The plugin.json sent from the server does not conform to our `struct Extension` definition: + // + // 1. Its `developer` field is a JSON object, but we need a string + // 2. sub-extensions won't have their `id` fields set + // + // we need to correct it + let mut plugin_json = archive.by_name(PLUGIN_JSON_FILE_NAME).map_err(|e| e.to_string())?; let mut plugin_json_content = String::new(); std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content) .map_err(|e| e.to_string())?; @@ -213,7 +220,6 @@ pub(crate) async fn install_extension(id: String) -> Result<(), String> { // Set IDs for sub-extensions (commands, quicklinks, scripts) let mut counter = 0; - // Set IDs for commands // Helper function to set IDs for array fields fn set_ids_for_field(extension: &mut Json, field_name: &str, counter: &mut i32) { if let Some(field) = extension.as_object_mut().unwrap().get_mut(field_name) { @@ -229,12 +235,11 @@ pub(crate) async fn install_extension(id: String) -> Result<(), String> { } } } - - // Set IDs for sub-extensions set_ids_for_field(&mut extension, "commands", &mut counter); set_ids_for_field(&mut extension, "quicklinks", &mut counter); set_ids_for_field(&mut extension, "scripts", &mut counter); + // Now the extension JSON is valid let mut extension: Extension = serde_json::from_value(extension).unwrap_or_else(|e| { panic!( "cannot parse plugin.json as struct Extension, error [{:?}]", @@ -244,57 +249,54 @@ pub(crate) async fn install_extension(id: String) -> Result<(), String> { drop(plugin_json); + + // Write extension files to the extension directory let developer = extension.developer.clone().unwrap_or_default(); let extension_id = extension.id.clone(); - - // Extract the zip file let extension_directory = { let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.to_path_buf(); path.push(developer); path.push(extension_id.as_str()); path }; - tokio::fs::create_dir_all(extension_directory.as_path()) .await .map_err(|e| e.to_string())?; // Extract all files except plugin.json for i in 0..archive.len() { - let mut file = archive.by_index(i).map_err(|e| e.to_string())?; - let outpath = match file.enclosed_name() { - Some(path) => extension_directory.join(path), - None => continue, - }; + let mut zip_file = archive.by_index(i).map_err(|e| e.to_string())?; + // `.name()` is safe to use in our cases, the cases listed in the below + // page won't happen to us. + // + // https://docs.rs/zip/4.2.0/zip/read/struct.ZipFile.html#method.name + // + // Example names: + // + // * `assets/icon.png` + // * `assets/screenshot.png` + // * `plugin.json` + // + // Yes, the `assets` directory is not a part of it. + let zip_file_name = zip_file.name(); // Skip the plugin.json file as we'll create it from the extension variable - if file.name() == "plugin.json" { + if zip_file_name == PLUGIN_JSON_FILE_NAME { continue; } - if file.name().ends_with('/') { - tokio::fs::create_dir_all(&outpath) - .await - .map_err(|e| e.to_string())?; - } else { - if let Some(p) = outpath.parent() { - if !p.exists() { - tokio::fs::create_dir_all(p) - .await - .map_err(|e| e.to_string())?; - } - } - let mut outfile = tokio::fs::File::create(&outpath) - .await - .map_err(|e| e.to_string())?; - let mut content = Vec::new(); - std::io::Read::read_to_end(&mut file, &mut content).map_err(|e| e.to_string())?; - tokio::io::AsyncWriteExt::write_all(&mut outfile, &content) - .await - .map_err(|e| e.to_string())?; - } - } + let dest_file_path = extension_directory.join(zip_file_name); + // For cases like `assets/xxx.png` + if let Some(parent_dir) = dest_file_path.parent() && !parent_dir.exists() { + tokio::fs::create_dir_all(parent_dir).await.map_err(|e| e.to_string())?; + } + + let mut dest_file = tokio::fs::File::create(&dest_file_path) .await .map_err(|e| e.to_string())?; + let mut src_bytes = Vec::with_capacity(zip_file.size().try_into().expect("we won't have a extension file that is bigger than 4GiB")); + zip_file.read_to_end(&mut src_bytes).map_err(|e| e.to_string())?; + tokio::io::copy(&mut src_bytes.as_slice(), &mut dest_file).await.map_err(|e| e.to_string())?; + } // Create plugin.json from the extension variable let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME); let extension_json = serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?; @@ -302,6 +304,7 @@ pub(crate) async fn install_extension(id: String) -> Result<(), String> { .await .map_err(|e| e.to_string())?; + // Turn it into an absolute path if it is a valid relative path because frontend code need this. canonicalize_relative_icon_path(&extension_directory, &mut extension)?; @@ -313,33 +316,3 @@ pub(crate) async fn install_extension(id: String) -> Result<(), String> { Ok(()) } - -#[tauri::command] -pub(crate) async fn uninstall_extension( - developer: String, - extension_id: String, -) -> Result<(), String> { - let extension_dir = { - let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.join(developer.as_str()); - path.push(extension_id.as_str()); - - path - }; - if !extension_dir.try_exists().map_err(|e| e.to_string())? { - panic!( - "we are uninstalling extension [{}/{}], but there is no such extension files on disk", - developer, extension_id - ) - } - tokio::fs::remove_dir_all(extension_dir.as_path()) - .await - .map_err(|e| e.to_string())?; - - THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE - .get() - .unwrap() - .remove_extension(&developer, &extension_id) - .await; - - Ok(()) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0df61eb1..e4b81314 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -159,9 +159,9 @@ pub fn run() { extension::register_extension_hotkey, extension::unregister_extension_hotkey, extension::is_extension_enabled, - extension::store::search_extension, - extension::store::install_extension, - extension::store::uninstall_extension, + extension::third_party::store::search_extension, + extension::third_party::store::install_extension_from_store, + extension::third_party::uninstall_extension, settings::set_allow_self_signature, settings::get_allow_self_signature, assistant::ask_ai, @@ -607,7 +607,12 @@ fn set_up_tauri_logger() -> TauriPlugin { // When running the built binary, set `COCO_LOG` to `coco_lib=trace` to capture all logs // that come from Coco in the log file, which helps with debugging. if !tauri::is_dev() { - std::env::set_var("COCO_LOG", "coco_lib=trace"); + // We have absolutely no guarantee that we (We have control over the Rust + // code, but definitely no idea about the libc C code, all the shared objects + // that we will link) will not concurrently read/write `envp`, so just use unsafe. + unsafe { + std::env::set_var("COCO_LOG", "coco_lib=trace"); + } } let mut builder = tauri_plugin_log::Builder::new(); diff --git a/src/components/Search/ExtensionStore.tsx b/src/components/Search/ExtensionStore.tsx index 6e373427..87470427 100644 --- a/src/components/Search/ExtensionStore.tsx +++ b/src/components/Search/ExtensionStore.tsx @@ -214,7 +214,7 @@ const ExtensionStore = () => { setInstallingExtensions(installingExtensions.concat(id)); - await platformAdapter.invokeBackend("install_extension", { id }); + await platformAdapter.invokeBackend("install_extension_from_store", { id }); toggleInstall();