mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-15 19:17:42 +01:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2025-06-06"
|
||||
channel = "nightly-2025-06-26"
|
||||
@@ -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<HashSet<Platform>>,
|
||||
/// 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 `<extension>/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<Extension>) -> 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
|
||||
|
||||
@@ -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<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[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::*;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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<tauri::Wry> {
|
||||
// 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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user