mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 03:27:43 +01:00
feat(extension compatibility): minimum_coco_version (#946)
This commit introduces a new field, `minimum_coco_version`, to the `plugin.json` JSON. It specifies the lowest Coco version required for an extension to run. This ensures better compatibility by preventing new extensions from being loaded on older Coco apps that may lack necessary APIs or features. Co-authored-by: ayang <473033518@qq.com>
This commit is contained in:
@@ -23,6 +23,7 @@ feat: return sub-exts when extension type exts themselves are matched #928
|
||||
feat: open quick ai with modifier key + enter #939
|
||||
feat: allow navigate back when cursor is at the beginning #940
|
||||
feat: add compact mode for window #947
|
||||
feat(extension compatibility): minimum_coco_version #946
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
|
||||
@@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
|
||||
name,
|
||||
platforms: None,
|
||||
developer: None,
|
||||
minimum_coco_version: None,
|
||||
// Leave it empty as it won't be used
|
||||
description: String::new(),
|
||||
icon: icon_path,
|
||||
|
||||
@@ -7,16 +7,20 @@ use crate::common::document::ExtensionOnOpenedType;
|
||||
use crate::common::document::OnOpened;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::util::platform::Platform;
|
||||
use crate::util::version::COCO_VERSION;
|
||||
use crate::util::version::parse_coco_semver;
|
||||
use anyhow::Context;
|
||||
use bitflags::bitflags;
|
||||
use borrowme::{Borrow, ToOwned};
|
||||
use derive_more::Display;
|
||||
use indexmap::IndexMap;
|
||||
use semver::Version as SemVer;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
@@ -24,6 +28,7 @@ use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
|
||||
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
|
||||
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
@@ -119,6 +124,16 @@ pub struct Extension {
|
||||
/// Permission that this extension requires.
|
||||
permission: Option<ExtensionPermission>,
|
||||
|
||||
/// The version of Coco app that this extension requires.
|
||||
///
|
||||
/// If not set, then this extension is compatible with all versions of Coco app.
|
||||
///
|
||||
/// It is only for third-party extensions. Built-in extensions should always
|
||||
/// set this field to `None`.
|
||||
#[serde(deserialize_with = "deserialize_coco_semver")]
|
||||
#[serde(default)] // None if this field is missing
|
||||
minimum_coco_version: Option<SemVer>,
|
||||
|
||||
/*
|
||||
* The following fields are currently useless to us but are needed by our
|
||||
* extension store.
|
||||
@@ -292,6 +307,9 @@ impl Extension {
|
||||
|
||||
Some(on_opened)
|
||||
}
|
||||
ExtensionType::Unknown => {
|
||||
unreachable!("Extensions of type [Unknown] should never be opened")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +384,26 @@ impl Extension {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize Coco SemVer from a string.
|
||||
///
|
||||
/// This function adapts `parse_coco_semver` to work with serde's `deserialize_with`
|
||||
/// attribute.
|
||||
fn deserialize_coco_semver<'de, D>(deserializer: D) -> Result<Option<SemVer>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let version_str: Option<String> = Option::deserialize(deserializer)?;
|
||||
let Some(version_str) = version_str else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(semver) = parse_coco_semver(&version_str) else {
|
||||
return Err(serde::de::Error::custom("version string format is invalid"));
|
||||
};
|
||||
|
||||
Ok(Some(semver))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
pub(crate) struct CommandAction {
|
||||
pub(crate) exec: String,
|
||||
@@ -569,6 +607,10 @@ pub enum ExtensionType {
|
||||
AiExtension,
|
||||
#[display("View")]
|
||||
View,
|
||||
/// Add this variant for better compatibility: Future versions of Coco may
|
||||
/// add new extension types that older versions of Coco are not aware of.
|
||||
#[display("Unknown")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ExtensionType {
|
||||
@@ -816,6 +858,22 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `extension` compatible with the current running Coco app?
|
||||
///
|
||||
/// It is defined as a tauri command rather than an associated function because
|
||||
/// it will be used in frontend code as well.
|
||||
///
|
||||
/// Async tauri commands are required to return `Result<T, E>`, this function
|
||||
/// only needs to return a boolean, so it is not marked async.
|
||||
#[tauri::command]
|
||||
pub(crate) fn is_extension_compatible(extension: Extension) -> bool {
|
||||
let Some(ref minimum_coco_version) = extension.minimum_coco_version else {
|
||||
return true;
|
||||
};
|
||||
|
||||
COCO_VERSION.deref() >= minimum_coco_version
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn enable_extension(
|
||||
tauri_app_handle: AppHandle,
|
||||
|
||||
24
src-tauri/src/extension/third_party/check.rs
vendored
24
src-tauri/src/extension/third_party/check.rs
vendored
@@ -14,6 +14,7 @@
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||
use crate::util::platform::Platform;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -179,6 +180,13 @@ fn check_sub_extension_only(
|
||||
}
|
||||
}
|
||||
|
||||
if sub_extension.minimum_coco_version.is_some() {
|
||||
return Err(format!(
|
||||
"invalid sub-extension [{}-{}]: [{}] cannot be set for sub-extensions",
|
||||
extension_id, sub_extension.id, PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -278,6 +286,7 @@ mod tests {
|
||||
ui: None,
|
||||
permission: None,
|
||||
settings: None,
|
||||
minimum_coco_version: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
@@ -541,6 +550,21 @@ mod tests {
|
||||
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_extension_cannot_set_minimum_coco_version() {
|
||||
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
|
||||
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
|
||||
sub_cmd.minimum_coco_version = Some(semver::Version::new(0, 8, 0));
|
||||
extension.commands = Some(vec![sub_cmd]);
|
||||
|
||||
let result = general_check(&extension);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains(&format!(
|
||||
"[{}] cannot be set for sub-extensions",
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
)));
|
||||
}
|
||||
/* Test check_sub_extension_only */
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::check_compatibility_via_mcv;
|
||||
use crate::extension::PLUGIN_JSON_FILE_NAME;
|
||||
use crate::extension::third_party::check::general_check;
|
||||
use crate::extension::third_party::install::{
|
||||
@@ -79,6 +80,10 @@ pub(crate) async fn install_local_extension(
|
||||
let mut extension_json: Json =
|
||||
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
|
||||
|
||||
if !check_compatibility_via_mcv(&extension_json)? {
|
||||
return Err("app_incompatible".into());
|
||||
}
|
||||
|
||||
// Set the main extension ID to the directory name
|
||||
let extension_obj = extension_json
|
||||
.as_object_mut()
|
||||
@@ -158,7 +163,7 @@ pub(crate) async fn install_local_extension(
|
||||
//
|
||||
// This is definitely error-prone, but we have to do this until we have
|
||||
// structured error type
|
||||
return Err("incompatible".into());
|
||||
return Err("platform_incompatible".into());
|
||||
}
|
||||
}
|
||||
/* Check ends here */
|
||||
|
||||
@@ -4,19 +4,27 @@
|
||||
//! # How
|
||||
//!
|
||||
//! Technically, installing an extension involves the following steps. The order
|
||||
//! may vary between implementations.
|
||||
//! varies between 2 implementations.
|
||||
//!
|
||||
//! 1. Check if it is already installed, if so, return
|
||||
//!
|
||||
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||
//! 2. Check if it is compatible by inspecting the "minimum_coco_version"
|
||||
//! field. If it is incompatible, reject and error out.
|
||||
//!
|
||||
//! This should be done before convert `plugin.json` JSON to `struct Extension`
|
||||
//! as the definition of `struct Extension` could change in the future, in this
|
||||
//! case, we want to tell users that "it is an incompatible extension" rather
|
||||
//! than "this extension is invalid".
|
||||
//!
|
||||
//! 3. Correct the `plugin.json` JSON if it does not conform to our `struct
|
||||
//! Extension` definition. This can happen because the JSON written by
|
||||
//! developers is in a simplified form for a better developer experience.
|
||||
//!
|
||||
//! 3. Validate the corrected `plugin.json`
|
||||
//! 4. Validate the corrected `plugin.json`
|
||||
//! 1. misc checks
|
||||
//! 2. Platform compatibility check
|
||||
//!
|
||||
//! 4. Write the extension files to the corresponding location
|
||||
//! 5. Write the extension files to the corresponding location
|
||||
//!
|
||||
//! * developer directory
|
||||
//! * extension directory
|
||||
@@ -25,25 +33,29 @@
|
||||
//! * plugin.json file
|
||||
//! * View pages if exist
|
||||
//!
|
||||
//! 5. If this extension contains any View extensions, call `convert_page()`
|
||||
//! 6. If this extension contains any View extensions, call `convert_page()`
|
||||
//! on them to make them loadable by Tauri/webview.
|
||||
//!
|
||||
//! See `convert_page()` for more info.
|
||||
//!
|
||||
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
||||
//! 7. Canonicalize `Extension.icon` and `Extension.page` fields if they are
|
||||
//! relative paths
|
||||
//!
|
||||
//! * icon: relative to the `assets` directory
|
||||
//! * page: relative to the extension root directory
|
||||
//!
|
||||
//! 7. Add the extension to the in-memory extension list.
|
||||
//! 8. Add the extension to the in-memory extension list.
|
||||
|
||||
pub(crate) mod local_extension;
|
||||
pub(crate) mod store;
|
||||
|
||||
use crate::extension::Extension;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||
use crate::util::platform::Platform;
|
||||
use crate::util::version::{COCO_VERSION, parse_coco_semver};
|
||||
use serde_json::Value as Json;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -287,6 +299,33 @@ async fn view_extension_convert_pages(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inspect the "minimum_coco_version" field and see if this extension is
|
||||
/// compatible with the current Coco app.
|
||||
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, String> {
|
||||
let Some(mcv_json) = plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) else {
|
||||
return Ok(true);
|
||||
};
|
||||
if mcv_json == &Json::Null {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let Some(mcv_str) = mcv_json.as_str() else {
|
||||
return Err(format!(
|
||||
"invalid extension: field [{}] should be a string",
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
));
|
||||
};
|
||||
|
||||
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||
return Err(format!(
|
||||
"invalid extension: [{}] is not a valid version string",
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
));
|
||||
};
|
||||
|
||||
Ok(COCO_VERSION.deref() >= &mcv)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -319,6 +358,7 @@ mod tests {
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
minimum_coco_version: None,
|
||||
permission: None,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Extension store related stuff.
|
||||
|
||||
use super::super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use super::check_compatibility_via_mcv;
|
||||
use super::is_extension_installed;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::common::document::Document;
|
||||
@@ -259,6 +260,10 @@ pub(crate) async fn install_extension_from_store(
|
||||
let mut extension: Json = serde_json::from_str(&plugin_json_content)
|
||||
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
|
||||
|
||||
if !check_compatibility_via_mcv(&extension)? {
|
||||
return Err("app_incompatible".into());
|
||||
}
|
||||
|
||||
let mut_ref_to_developer_object: &mut Json = extension
|
||||
.as_object_mut()
|
||||
.expect("plugin.json should be an object")
|
||||
@@ -308,7 +313,7 @@ pub(crate) async fn install_extension_from_store(
|
||||
let current_platform = Platform::current();
|
||||
if let Some(ref platforms) = extension.platforms {
|
||||
if !platforms.contains(¤t_platform) {
|
||||
return Err("this extension is not compatible with your OS".into());
|
||||
return Err("platform_incompatible".into());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
src-tauri/src/extension/third_party/mod.rs
vendored
163
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -16,15 +16,22 @@ use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::ExtensionBundleIdBorrowed;
|
||||
use crate::extension::ExtensionType;
|
||||
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
|
||||
use crate::extension::calculate_text_similarity;
|
||||
use crate::extension::canonicalize_relative_page_path;
|
||||
use crate::extension::is_extension_compatible;
|
||||
use crate::util::platform::Platform;
|
||||
use crate::util::version::COCO_VERSION;
|
||||
use crate::util::version::parse_coco_semver;
|
||||
use async_trait::async_trait;
|
||||
use borrowme::ToOwned;
|
||||
use check::general_check;
|
||||
use function_name::named;
|
||||
use semver::Version as SemVer;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use std::io::ErrorKind;
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -124,6 +131,154 @@ pub(crate) async fn load_third_party_extensions_from_directory(
|
||||
let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let plugin_json = match serde_json::from_str::<Json>(&plugin_json_file_content) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: file [{}] is not a JSON, error: '{}'",
|
||||
extension_dir_file_name,
|
||||
plugin_json_file_path.display(),
|
||||
e
|
||||
);
|
||||
continue 'extension;
|
||||
}
|
||||
};
|
||||
let opt_mcv: Option<SemVer> = {
|
||||
match plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) {
|
||||
None => None,
|
||||
// NULL is considered None as well.
|
||||
Some(Json::Null) => None,
|
||||
|
||||
Some(mcv_json) => {
|
||||
let Some(mcv_str) = mcv_json.as_str() else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [{}] is not a string",
|
||||
extension_dir_file_name,
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(mcv) = parse_coco_semver(mcv_str) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [{}] has invalid version string",
|
||||
extension_dir_file_name,
|
||||
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
Some(mcv)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let is_compatible: bool = match opt_mcv {
|
||||
Some(ref mcv) => COCO_VERSION.deref() >= mcv,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if !is_compatible {
|
||||
/*
|
||||
* Extract only these field: [id, name, icon, type] from the JSON,
|
||||
* then return a minimal Extension instance with these fields set:
|
||||
*
|
||||
* - `id` and `developer`: to make it identifiable
|
||||
* - `name`, `icon` and `type`: to display it in the Extensions page
|
||||
* - `minimum_coco_version`: so that we can check compatibility using it
|
||||
*/
|
||||
let Some(id) = plugin_json.get("id").and_then(|v| v.as_str()) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [id] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(name) = plugin_json.get("name").and_then(|v| v.as_str()) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [name] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(icon) = plugin_json.get("icon").and_then(|v| v.as_str()) else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [icon] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let Some(extension_type_str) = plugin_json.get("type").and_then(|v| v.as_str())
|
||||
else {
|
||||
log::warn!(
|
||||
"invalid extension: [{}]: field [type] is missing or not a string",
|
||||
extension_dir_file_name,
|
||||
);
|
||||
continue 'extension;
|
||||
};
|
||||
|
||||
let extension_type: ExtensionType = match serde_plain::from_str(extension_type_str)
|
||||
{
|
||||
Ok(t) => t,
|
||||
// Future Coco may have new Extension types that the we don't know
|
||||
//
|
||||
// This should be the only place where `ExtensionType::Unknown`
|
||||
// could be constructed.
|
||||
Err(_e) => ExtensionType::Unknown,
|
||||
};
|
||||
|
||||
// We don't extract the developer ID from the plugin.json to rely
|
||||
// less on it.
|
||||
let developer = developer_dir
|
||||
.file_name()
|
||||
.into_string()
|
||||
.expect("developer ID should be UTF-8 encoded");
|
||||
|
||||
let mut incompatible_extension = Extension {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
icon: icon.to_string(),
|
||||
r#type: extension_type,
|
||||
developer: Some(developer),
|
||||
description: String::new(),
|
||||
enabled: false,
|
||||
platforms: None,
|
||||
action: None,
|
||||
quicklink: None,
|
||||
commands: None,
|
||||
scripts: None,
|
||||
quicklinks: None,
|
||||
views: None,
|
||||
alias: None,
|
||||
hotkey: None,
|
||||
settings: None,
|
||||
page: None,
|
||||
ui: None,
|
||||
permission: None,
|
||||
minimum_coco_version: opt_mcv,
|
||||
screenshots: None,
|
||||
url: None,
|
||||
version: None,
|
||||
};
|
||||
|
||||
// Turn icon path into an absolute path if it is a valid relative path
|
||||
canonicalize_relative_icon_path(
|
||||
&extension_dir.path(),
|
||||
&mut incompatible_extension,
|
||||
)?;
|
||||
// No need to canonicalize the path field as it is not set
|
||||
|
||||
extensions.push(incompatible_extension);
|
||||
continue 'extension;
|
||||
}
|
||||
|
||||
/*
|
||||
* This is a compatible extension.
|
||||
*/
|
||||
let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
|
||||
Ok(extension) => extension,
|
||||
Err(e) => {
|
||||
@@ -807,7 +962,11 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
let extensions_read_lock =
|
||||
futures::executor::block_on(async { inner_clone.extensions.read().await });
|
||||
|
||||
for extension in extensions_read_lock.iter().filter(|ext| ext.enabled) {
|
||||
for extension in extensions_read_lock
|
||||
.iter()
|
||||
// field minimum_coco_extension is only set for main extensions.
|
||||
.filter(|ext| ext.enabled && is_extension_compatible(Extension::clone(ext)))
|
||||
{
|
||||
if extension.r#type.contains_sub_items() {
|
||||
let opt_main_extension_lowercase_name =
|
||||
if extension.r#type == ExtensionType::Extension {
|
||||
@@ -857,7 +1016,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
}
|
||||
|
||||
if let Some(ref views) = extension.views {
|
||||
for view in views.iter().filter(|link| link.enabled) {
|
||||
for view in views.iter().filter(|view| view.enabled) {
|
||||
if let Some(hit) = extension_to_hit(
|
||||
view,
|
||||
&query_lower,
|
||||
|
||||
@@ -167,6 +167,7 @@ pub fn run() {
|
||||
extension::third_party::install::store::install_extension_from_store,
|
||||
extension::third_party::install::local_extension::install_local_extension,
|
||||
extension::third_party::uninstall_extension,
|
||||
extension::is_extension_compatible,
|
||||
extension::api::apis,
|
||||
extension::api::fs::read_dir,
|
||||
settings::set_allow_self_signature,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use semver::{BuildMetadata, Prerelease, Version};
|
||||
use semver::{BuildMetadata, Prerelease, Version as SemVer};
|
||||
use std::sync::LazyLock;
|
||||
use tauri_plugin_updater::RemoteRelease;
|
||||
|
||||
const SNAPSHOT_DASH: &str = "SNAPSHOT-";
|
||||
@@ -6,8 +7,15 @@ const SNAPSHOT_DASH_LEN: usize = SNAPSHOT_DASH.len();
|
||||
// trim the last dash
|
||||
const SNAPSHOT: &str = SNAPSHOT_DASH.split_at(SNAPSHOT_DASH_LEN - 1).0;
|
||||
|
||||
/// Coco app version, in SemVer format.
|
||||
pub(crate) static COCO_VERSION: LazyLock<SemVer> = LazyLock::new(|| {
|
||||
parse_coco_semver(env!("CARGO_PKG_VERSION")).expect("parsing should never fail, if version format changes, then parse_coco_semver() should be updated as well")
|
||||
});
|
||||
|
||||
/// Coco AI app adopt SemVer but the version string format does not adhere to
|
||||
/// the SemVer specification, this function does the conversion.
|
||||
/// the SemVer specification, this function does the conversion. Returns `None`
|
||||
/// if the input is not in the expected format so that the conversion cannot
|
||||
/// complete.
|
||||
///
|
||||
/// # Example cases
|
||||
///
|
||||
@@ -22,11 +30,11 @@ const SNAPSHOT: &str = SNAPSHOT_DASH.split_at(SNAPSHOT_DASH_LEN - 1).0;
|
||||
/// * 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||
///
|
||||
/// A pre-release of 0.9.0
|
||||
fn to_semver(version: &Version) -> Version {
|
||||
fn to_semver(version: &SemVer) -> Option<SemVer> {
|
||||
let pre = &version.pre;
|
||||
|
||||
if pre.is_empty() {
|
||||
return Version::new(version.major, version.minor, version.patch);
|
||||
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||
}
|
||||
let is_pre_release = pre.starts_with(SNAPSHOT_DASH);
|
||||
|
||||
@@ -36,15 +44,11 @@ fn to_semver(version: &Version) -> Version {
|
||||
pre.as_str()
|
||||
};
|
||||
// Parse the build number to validate it, we do not need the actual number though.
|
||||
build_number_str.parse::<usize>().unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"looks like Coco changed the version string format, but forgot to update this function"
|
||||
);
|
||||
});
|
||||
build_number_str.parse::<usize>().ok()?;
|
||||
|
||||
// Return after checking the build number is valid
|
||||
if !is_pre_release {
|
||||
return Version::new(version.major, version.minor, version.patch);
|
||||
return Some(SemVer::new(version.major, version.minor, version.patch));
|
||||
}
|
||||
|
||||
let pre = {
|
||||
@@ -52,16 +56,23 @@ fn to_semver(version: &Version) -> Version {
|
||||
Prerelease::new(&pre_str).unwrap_or_else(|e| panic!("invalid Prerelease: {}", e))
|
||||
};
|
||||
|
||||
Version {
|
||||
Some(SemVer {
|
||||
major: version.major,
|
||||
minor: version.minor,
|
||||
patch: version.patch,
|
||||
pre,
|
||||
build: BuildMetadata::EMPTY,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn custom_version_comparator(local: Version, remote_release: RemoteRelease) -> bool {
|
||||
/// Parse Coco version string to a `SemVer`. Returns `None` if it is not a valid
|
||||
/// version string.
|
||||
pub(crate) fn parse_coco_semver(version_str: &str) -> Option<SemVer> {
|
||||
let not_semver = SemVer::parse(version_str).ok()?;
|
||||
to_semver(¬_semver)
|
||||
}
|
||||
|
||||
pub(crate) fn custom_version_comparator(local: SemVer, remote_release: RemoteRelease) -> bool {
|
||||
let remote = remote_release.version;
|
||||
let local_semver = to_semver(&local);
|
||||
let remote_semver = to_semver(&remote);
|
||||
@@ -88,8 +99,8 @@ mod tests {
|
||||
fn test_try_into_semver_local_dev() {
|
||||
// Case: 0.8.0 => 0.8.0
|
||||
// Local development version without any pre-release or build metadata
|
||||
let input = Version::parse("0.8.0").unwrap();
|
||||
let result = to_semver(&input);
|
||||
let input = SemVer::parse("0.8.0").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 0);
|
||||
assert_eq!(result.minor, 8);
|
||||
@@ -103,8 +114,8 @@ mod tests {
|
||||
fn test_try_into_semver_official_release() {
|
||||
// Case: 0.8.0-<build num> => 0.8.0
|
||||
// Official release with build number in pre-release field
|
||||
let input = Version::parse("0.8.0-123").unwrap();
|
||||
let result = to_semver(&input);
|
||||
let input = SemVer::parse("0.8.0-123").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 0);
|
||||
assert_eq!(result.minor, 8);
|
||||
@@ -118,8 +129,8 @@ mod tests {
|
||||
fn test_try_into_semver_pre_release() {
|
||||
// Case: 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
|
||||
// Pre-release version with SNAPSHOT prefix
|
||||
let input = Version::parse("0.9.0-SNAPSHOT-456").unwrap();
|
||||
let result = to_semver(&input);
|
||||
let input = SemVer::parse("0.9.0-SNAPSHOT-456").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 0);
|
||||
assert_eq!(result.minor, 9);
|
||||
@@ -132,8 +143,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_try_into_semver_official_release_different_version() {
|
||||
// Test with different version numbers
|
||||
let input = Version::parse("1.2.3-9999").unwrap();
|
||||
let result = to_semver(&input);
|
||||
let input = SemVer::parse("1.2.3-9999").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 1);
|
||||
assert_eq!(result.minor, 2);
|
||||
@@ -146,8 +157,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_try_into_semver_snapshot_different_version() {
|
||||
// Test SNAPSHOT with different version numbers
|
||||
let input = Version::parse("2.0.0-SNAPSHOT-777").unwrap();
|
||||
let result = to_semver(&input);
|
||||
let input = SemVer::parse("2.0.0-SNAPSHOT-777").unwrap();
|
||||
let result = to_semver(&input).unwrap();
|
||||
|
||||
assert_eq!(result.major, 2);
|
||||
assert_eq!(result.minor, 0);
|
||||
@@ -158,28 +169,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "looks like Coco changed the version string format")]
|
||||
fn test_try_into_semver_invalid_build_number() {
|
||||
// Should panic when build number is not a valid number
|
||||
let input = Version::parse("0.8.0-abc").unwrap();
|
||||
to_semver(&input);
|
||||
let input = SemVer::parse("0.8.0-abc").unwrap();
|
||||
assert!(to_semver(&input).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "looks like Coco changed the version string format")]
|
||||
fn test_try_into_semver_invalid_snapshot_build_number() {
|
||||
// Should panic when SNAPSHOT build number is not a valid number
|
||||
let input = Version::parse("0.9.0-SNAPSHOT-xyz").unwrap();
|
||||
to_semver(&input);
|
||||
let input = SemVer::parse("0.9.0-SNAPSHOT-xyz").unwrap();
|
||||
assert!(to_semver(&input).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_version_comparator() {
|
||||
fn new_local(str: &str) -> Version {
|
||||
Version::parse(str).unwrap()
|
||||
fn new_local(str: &str) -> SemVer {
|
||||
SemVer::parse(str).unwrap()
|
||||
}
|
||||
fn new_remote_release(str: &str) -> RemoteRelease {
|
||||
let version = Version::parse(str).unwrap();
|
||||
let version = SemVer::parse(str).unwrap();
|
||||
|
||||
RemoteRelease {
|
||||
version,
|
||||
|
||||
@@ -5,7 +5,7 @@ import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { parseSearchQuery } from "@/utils";
|
||||
import { installExtensionError, parseSearchQuery } from "@/utils";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import SearchEmpty from "../Common/SearchEmpty";
|
||||
import ExtensionDetail from "./ExtensionDetail";
|
||||
@@ -244,7 +244,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
|
||||
"info"
|
||||
);
|
||||
} catch (error) {
|
||||
addError(String(error), "error");
|
||||
installExtensionError(String(error));
|
||||
} finally {
|
||||
const { installingExtensions } = useSearchStore.getState();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, MouseEvent, useContext, useMemo, useState } from "react";
|
||||
import { useReactive } from "ahooks";
|
||||
import { useMount, useReactive } from "ahooks";
|
||||
import { ChevronRight, LoaderCircle } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { isArray, startCase, sortBy } from "lodash-es";
|
||||
@@ -20,11 +20,12 @@ const Content = () => {
|
||||
return rootState.extensions.map((item) => {
|
||||
const { id } = item;
|
||||
|
||||
return <Item key={id} {...item} level={1} />;
|
||||
return <Item key={id} extension={item} level={1} />;
|
||||
});
|
||||
};
|
||||
|
||||
interface ItemProps extends Extension {
|
||||
interface ItemProps {
|
||||
extension: Extension;
|
||||
level: number;
|
||||
parentId?: ExtensionId;
|
||||
parentDeveloper?: string;
|
||||
@@ -42,19 +43,8 @@ const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
|
||||
};
|
||||
|
||||
const Item: FC<ItemProps> = (props) => {
|
||||
const {
|
||||
id,
|
||||
icon,
|
||||
name,
|
||||
type,
|
||||
level,
|
||||
platforms,
|
||||
developer,
|
||||
enabled,
|
||||
parentId,
|
||||
parentDeveloper,
|
||||
parentDisabled,
|
||||
} = props;
|
||||
const { extension, level, parentId, parentDeveloper, parentDisabled } = props;
|
||||
const { id, icon, name, type, platforms, developer, enabled } = extension;
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
const state = useReactive<ItemState>({
|
||||
loading: false,
|
||||
@@ -63,6 +53,18 @@ const Item: FC<ItemProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { disabledExtensions, setDisabledExtensions } = useExtensionsStore();
|
||||
const [selfDisabled, setSelfDisabled] = useState(!enabled);
|
||||
const [compatible, setCompatible] = useState(true);
|
||||
|
||||
useMount(async () => {
|
||||
const compatible = await platformAdapter.invokeBackend<boolean>(
|
||||
"is_extension_compatible",
|
||||
{
|
||||
extension,
|
||||
}
|
||||
);
|
||||
|
||||
setCompatible(compatible);
|
||||
});
|
||||
|
||||
const bundleId = {
|
||||
developer: developer ?? parentDeveloper,
|
||||
@@ -71,7 +73,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
};
|
||||
|
||||
const hasSubExtensions = () => {
|
||||
const { commands, scripts, quicklinks } = props;
|
||||
const { commands, scripts, quicklinks } = extension;
|
||||
|
||||
if (subExtensionCommand[id]) {
|
||||
return true;
|
||||
@@ -87,7 +89,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
const getSubExtensions = async () => {
|
||||
state.loading = true;
|
||||
|
||||
const { commands, scripts, quicklinks } = props;
|
||||
const { commands, scripts, quicklinks } = extension;
|
||||
|
||||
let subExtensions: Extension[] = [];
|
||||
|
||||
@@ -117,12 +119,16 @@ const Item: FC<ItemProps> = (props) => {
|
||||
};
|
||||
|
||||
const isDisabled = useMemo(() => {
|
||||
if (!compatible) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (level === 1) {
|
||||
return selfDisabled;
|
||||
}
|
||||
|
||||
return parentDisabled || selfDisabled;
|
||||
}, [parentDisabled, selfDisabled]);
|
||||
}, [parentDisabled, selfDisabled, compatible]);
|
||||
|
||||
const editable = useMemo(() => {
|
||||
return (
|
||||
@@ -134,7 +140,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
}, [type]);
|
||||
|
||||
const renderAlias = () => {
|
||||
const { alias } = props;
|
||||
const { alias } = extension;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
platformAdapter.invokeBackend("set_extension_alias", {
|
||||
@@ -173,7 +179,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
};
|
||||
|
||||
const renderHotkey = () => {
|
||||
const { hotkey } = props;
|
||||
const { hotkey } = extension;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
if (value) {
|
||||
@@ -246,7 +252,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("flex items-center justify-end", {
|
||||
"opacity-50 pointer-events-none": parentDisabled,
|
||||
"opacity-50 pointer-events-none": !compatible || parentDisabled,
|
||||
})}
|
||||
>
|
||||
<SettingsToggle
|
||||
@@ -294,7 +300,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 h-8"
|
||||
onClick={() => {
|
||||
rootState.activeExtension = props;
|
||||
rootState.activeExtension = extension;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -356,7 +362,7 @@ const Item: FC<ItemProps> = (props) => {
|
||||
return (
|
||||
<Item
|
||||
key={item.id}
|
||||
{...item}
|
||||
extension={item}
|
||||
level={level + 1}
|
||||
parentId={id}
|
||||
parentDeveloper={developer}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
import { ExtensionsContext } from "../..";
|
||||
import Applications from "./Applications";
|
||||
@@ -8,11 +8,12 @@ import SharedAi from "./SharedAi";
|
||||
import AiOverview from "./AiOverview";
|
||||
import Calculator from "./Calculator";
|
||||
import FileSearch from "./FileSearch";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { Ellipsis, Info } from "lucide-react";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
|
||||
const Details = () => {
|
||||
const { rootState } = useContext(ExtensionsContext);
|
||||
@@ -33,6 +34,23 @@ const Details = () => {
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [compatible, setCompatible] = useState(true);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (rootState.activeExtension?.id) {
|
||||
const compatible = await platformAdapter.invokeBackend<boolean>(
|
||||
"is_extension_compatible",
|
||||
{
|
||||
extension: rootState.activeExtension,
|
||||
}
|
||||
);
|
||||
|
||||
setCompatible(compatible);
|
||||
} else {
|
||||
setCompatible(true);
|
||||
}
|
||||
}, [rootState.activeExtension?.id]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!rootState.activeExtension) return;
|
||||
|
||||
@@ -77,7 +95,7 @@ const Details = () => {
|
||||
return (
|
||||
<div className="flex-1 h-full pr-4 pb-4 overflow-auto">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<h2 className="m-0 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{rootState.activeExtension?.name}
|
||||
</h2>
|
||||
|
||||
@@ -130,6 +148,16 @@ const Details = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!compatible && (
|
||||
<div className="-mt-1 mb-3 bg-red-50 p-2 rounded">
|
||||
<Info className="inline-flex size-4 mr-1 text-red-600" />
|
||||
|
||||
<span className="text-[#333]">
|
||||
{t("settings.extensions.hints.incompatible")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import Details from "./components/Details";
|
||||
import { useExtensionsStore } from "@/stores/extensionsStore";
|
||||
import SettingsInput from "../SettingsInput";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { installExtensionError } from "@/utils";
|
||||
|
||||
export type ExtensionId = LiteralUnion<
|
||||
| "Applications"
|
||||
@@ -32,7 +33,9 @@ type ExtensionType =
|
||||
| "setting"
|
||||
| "calculator"
|
||||
| "command"
|
||||
| "ai_extension";
|
||||
| "ai_extension"
|
||||
| "view"
|
||||
| "unknown";
|
||||
|
||||
export type ExtensionPlatform = "windows" | "macos" | "linux";
|
||||
|
||||
@@ -63,9 +66,9 @@ export interface ExtensionPermission {
|
||||
}
|
||||
|
||||
export interface ViewExtensionUISettings {
|
||||
search_bar: boolean,
|
||||
filter_bar: boolean,
|
||||
footer: boolean,
|
||||
search_bar: boolean;
|
||||
filter_bar: boolean;
|
||||
footer: boolean;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
@@ -143,6 +146,8 @@ export const Extensions = () => {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("extensions", cloneDeep(extensions));
|
||||
|
||||
state.extensions = sortBy(extensions, ["name"]);
|
||||
|
||||
if (configId) {
|
||||
@@ -228,21 +233,7 @@ export const Extensions = () => {
|
||||
"info"
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
|
||||
if (errorMessage === "already imported") {
|
||||
addError(
|
||||
t(
|
||||
"settings.extensions.hints.extensionAlreadyImported"
|
||||
)
|
||||
);
|
||||
} else if (errorMessage === "incompatible") {
|
||||
addError(
|
||||
t("settings.extensions.hints.incompatibleExtension")
|
||||
);
|
||||
} else {
|
||||
addError(t("settings.extensions.hints.importFailed"));
|
||||
}
|
||||
installExtensionError(String(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -274,6 +274,7 @@ export default function GeneralSettings() {
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setWindowMode(value);
|
||||
}}
|
||||
|
||||
@@ -222,9 +222,11 @@
|
||||
"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.",
|
||||
"incompatibleExtension": "This extension is incompatible with your OS.",
|
||||
"platformIncompatibleExtension": "This extension is incompatible with your OS.",
|
||||
"appIncompatibleExtension": "Installation failed! Incompatible with your Coco App version. Please update and retry.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallSuccess": "Uninstalled successfully"
|
||||
"uninstallSuccess": "Uninstalled successfully",
|
||||
"incompatible": "Extension cannot run on the current version. Please upgrade Coco App."
|
||||
},
|
||||
"application": {
|
||||
"title": "Applications",
|
||||
|
||||
@@ -222,9 +222,11 @@
|
||||
"importSuccess": "插件导入成功。",
|
||||
"importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。",
|
||||
"extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。",
|
||||
"incompatibleExtension": "此插件与当前操作系统不兼容。",
|
||||
"platformIncompatibleExtension": "此插件与当前操作系统不兼容。",
|
||||
"appIncompatibleExtension": "安装失败!该插件与当前 Coco App 版本不兼容,请升级后重试。",
|
||||
"uninstall": "卸载",
|
||||
"uninstallSuccess": "卸载成功"
|
||||
"uninstallSuccess": "卸载成功",
|
||||
"incompatible": "扩展无法在当前版本中运行,请升级 Coco App。"
|
||||
},
|
||||
"application": {
|
||||
"title": "应用程序",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { getCurrentWindowService } from "@/commands/windowService";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import i18next from "i18next";
|
||||
|
||||
// 1
|
||||
export async function copyToClipboard(text: string) {
|
||||
@@ -326,3 +327,23 @@ export const visibleFooterBar = () => {
|
||||
|
||||
return ui?.footer ?? true;
|
||||
};
|
||||
|
||||
export const installExtensionError = (error: string) => {
|
||||
const { addError } = useAppStore.getState();
|
||||
|
||||
let message = "settings.extensions.hints.importFailed";
|
||||
|
||||
if (error === "already imported") {
|
||||
message = "settings.extensions.hints.extensionAlreadyImported";
|
||||
}
|
||||
|
||||
if (error === "platform_incompatible") {
|
||||
message = "settings.extensions.hints.platformIncompatibleExtension";
|
||||
}
|
||||
|
||||
if (error === "app_incompatible") {
|
||||
message = "settings.extensions.hints.appIncompatibleExtension";
|
||||
}
|
||||
|
||||
addError(i18next.t(message));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user