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:
SteveLauC
2025-11-02 10:59:29 +08:00
committed by GitHub
parent bd61faf660
commit 3e0839f3da
18 changed files with 449 additions and 95 deletions

View File

@@ -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: open quick ai with modifier key + enter #939
feat: allow navigate back when cursor is at the beginning #940 feat: allow navigate back when cursor is at the beginning #940
feat: add compact mode for window #947 feat: add compact mode for window #947
feat(extension compatibility): minimum_coco_version #946
### 🐛 Bug fix ### 🐛 Bug fix

View File

@@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
name, name,
platforms: None, platforms: None,
developer: None, developer: None,
minimum_coco_version: None,
// Leave it empty as it won't be used // Leave it empty as it won't be used
description: String::new(), description: String::new(),
icon: icon_path, icon: icon_path,

View File

@@ -7,16 +7,20 @@ use crate::common::document::ExtensionOnOpenedType;
use crate::common::document::OnOpened; use crate::common::document::OnOpened;
use crate::common::register::SearchSourceRegistry; use crate::common::register::SearchSourceRegistry;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use anyhow::Context; use anyhow::Context;
use bitflags::bitflags; use bitflags::bitflags;
use borrowme::{Borrow, ToOwned}; use borrowme::{Borrow, ToOwned};
use derive_more::Display; use derive_more::Display;
use indexmap::IndexMap; use indexmap::IndexMap;
use semver::Version as SemVer;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::Value as Json; use serde_json::Value as Json;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::ops::Deref;
use std::path::Path; use std::path::Path;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; 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"; pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json"; const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets"; const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
fn default_true() -> bool { fn default_true() -> bool {
true true
@@ -119,6 +124,16 @@ pub struct Extension {
/// Permission that this extension requires. /// Permission that this extension requires.
permission: Option<ExtensionPermission>, 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 * The following fields are currently useless to us but are needed by our
* extension store. * extension store.
@@ -292,6 +307,9 @@ impl Extension {
Some(on_opened) 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)] #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct CommandAction { pub(crate) struct CommandAction {
pub(crate) exec: String, pub(crate) exec: String,
@@ -569,6 +607,10 @@ pub enum ExtensionType {
AiExtension, AiExtension,
#[display("View")] #[display("View")]
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 { impl ExtensionType {
@@ -816,6 +858,22 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
Ok(()) 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] #[tauri::command]
pub(crate) async fn enable_extension( pub(crate) async fn enable_extension(
tauri_app_handle: AppHandle, tauri_app_handle: AppHandle,

View File

@@ -14,6 +14,7 @@
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::ExtensionType; use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use std::collections::HashSet; 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(()) Ok(())
} }
@@ -278,6 +286,7 @@ mod tests {
ui: None, ui: None,
permission: None, permission: None,
settings: None, settings: None,
minimum_coco_version: None,
screenshots: None, screenshots: None,
url: None, url: None,
version: None, version: None,
@@ -541,6 +550,21 @@ mod tests {
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions" "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 check_sub_extension_only */
#[test] #[test]

View File

@@ -1,3 +1,4 @@
use super::check_compatibility_via_mcv;
use crate::extension::PLUGIN_JSON_FILE_NAME; use crate::extension::PLUGIN_JSON_FILE_NAME;
use crate::extension::third_party::check::general_check; use crate::extension::third_party::check::general_check;
use crate::extension::third_party::install::{ use crate::extension::third_party::install::{
@@ -79,6 +80,10 @@ pub(crate) async fn install_local_extension(
let mut extension_json: Json = let mut extension_json: Json =
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?; 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 // Set the main extension ID to the directory name
let extension_obj = extension_json let extension_obj = extension_json
.as_object_mut() .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 // This is definitely error-prone, but we have to do this until we have
// structured error type // structured error type
return Err("incompatible".into()); return Err("platform_incompatible".into());
} }
} }
/* Check ends here */ /* Check ends here */

View File

@@ -4,19 +4,27 @@
//! # How //! # How
//! //!
//! Technically, installing an extension involves the following steps. The order //! 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 //! 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 //! Extension` definition. This can happen because the JSON written by
//! developers is in a simplified form for a better developer experience. //! 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 //! 1. misc checks
//! 2. Platform compatibility check //! 2. Platform compatibility check
//! //!
//! 4. Write the extension files to the corresponding location //! 5. Write the extension files to the corresponding location
//! //!
//! * developer directory //! * developer directory
//! * extension directory //! * extension directory
@@ -25,25 +33,29 @@
//! * plugin.json file //! * plugin.json file
//! * View pages if exist //! * 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. //! on them to make them loadable by Tauri/webview.
//! //!
//! See `convert_page()` for more info. //! 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 //! relative paths
//! //!
//! * icon: relative to the `assets` directory //! * icon: relative to the `assets` directory
//! * page: relative to the extension root 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 local_extension;
pub(crate) mod store; pub(crate) mod store;
use crate::extension::Extension; use crate::extension::Extension;
use crate::extension::ExtensionType; use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::util::platform::Platform; 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::Path;
use std::path::PathBuf; use std::path::PathBuf;
@@ -287,6 +299,33 @@ async fn view_extension_convert_pages(
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -319,6 +358,7 @@ mod tests {
settings: None, settings: None,
page: None, page: None,
ui: None, ui: None,
minimum_coco_version: None,
permission: None, permission: None,
screenshots: None, screenshots: None,
url: None, url: None,

View File

@@ -1,6 +1,7 @@
//! Extension store related stuff. //! Extension store related stuff.
use super::super::LOCAL_QUERY_SOURCE_TYPE; use super::super::LOCAL_QUERY_SOURCE_TYPE;
use super::check_compatibility_via_mcv;
use super::is_extension_installed; use super::is_extension_installed;
use crate::common::document::DataSourceReference; use crate::common::document::DataSourceReference;
use crate::common::document::Document; 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) let mut extension: Json = serde_json::from_str(&plugin_json_content)
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?; .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 let mut_ref_to_developer_object: &mut Json = extension
.as_object_mut() .as_object_mut()
.expect("plugin.json should be an object") .expect("plugin.json should be an object")
@@ -308,7 +313,7 @@ pub(crate) async fn install_extension_from_store(
let current_platform = Platform::current(); let current_platform = Platform::current();
if let Some(ref platforms) = extension.platforms { if let Some(ref platforms) = extension.platforms {
if !platforms.contains(&current_platform) { if !platforms.contains(&current_platform) {
return Err("this extension is not compatible with your OS".into()); return Err("platform_incompatible".into());
} }
} }

View File

@@ -16,15 +16,22 @@ use crate::common::search::SearchQuery;
use crate::common::traits::SearchSource; use crate::common::traits::SearchSource;
use crate::extension::ExtensionBundleIdBorrowed; use crate::extension::ExtensionBundleIdBorrowed;
use crate::extension::ExtensionType; use crate::extension::ExtensionType;
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
use crate::extension::calculate_text_similarity; use crate::extension::calculate_text_similarity;
use crate::extension::canonicalize_relative_page_path; use crate::extension::canonicalize_relative_page_path;
use crate::extension::is_extension_compatible;
use crate::util::platform::Platform; use crate::util::platform::Platform;
use crate::util::version::COCO_VERSION;
use crate::util::version::parse_coco_semver;
use async_trait::async_trait; use async_trait::async_trait;
use borrowme::ToOwned; use borrowme::ToOwned;
use check::general_check; use check::general_check;
use function_name::named; use function_name::named;
use semver::Version as SemVer;
use serde_json::Value as Json;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::ErrorKind; use std::io::ErrorKind;
use std::ops::Deref;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; 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) let plugin_json_file_content = tokio::fs::read_to_string(&plugin_json_file_path)
.await .await
.map_err(|e| e.to_string())?; .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) { let mut extension = match serde_json::from_str::<Extension>(&plugin_json_file_content) {
Ok(extension) => extension, Ok(extension) => extension,
Err(e) => { Err(e) => {
@@ -807,7 +962,11 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
let extensions_read_lock = let extensions_read_lock =
futures::executor::block_on(async { inner_clone.extensions.read().await }); 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() { if extension.r#type.contains_sub_items() {
let opt_main_extension_lowercase_name = let opt_main_extension_lowercase_name =
if extension.r#type == ExtensionType::Extension { if extension.r#type == ExtensionType::Extension {
@@ -857,7 +1016,7 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
} }
if let Some(ref views) = extension.views { 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( if let Some(hit) = extension_to_hit(
view, view,
&query_lower, &query_lower,

View File

@@ -167,6 +167,7 @@ pub fn run() {
extension::third_party::install::store::install_extension_from_store, extension::third_party::install::store::install_extension_from_store,
extension::third_party::install::local_extension::install_local_extension, extension::third_party::install::local_extension::install_local_extension,
extension::third_party::uninstall_extension, extension::third_party::uninstall_extension,
extension::is_extension_compatible,
extension::api::apis, extension::api::apis,
extension::api::fs::read_dir, extension::api::fs::read_dir,
settings::set_allow_self_signature, settings::set_allow_self_signature,

View File

@@ -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; use tauri_plugin_updater::RemoteRelease;
const SNAPSHOT_DASH: &str = "SNAPSHOT-"; const SNAPSHOT_DASH: &str = "SNAPSHOT-";
@@ -6,8 +7,15 @@ const SNAPSHOT_DASH_LEN: usize = SNAPSHOT_DASH.len();
// trim the last dash // trim the last dash
const SNAPSHOT: &str = SNAPSHOT_DASH.split_at(SNAPSHOT_DASH_LEN - 1).0; 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 /// 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 /// # 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> /// * 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
/// ///
/// A pre-release of 0.9.0 /// A pre-release of 0.9.0
fn to_semver(version: &Version) -> Version { fn to_semver(version: &SemVer) -> Option<SemVer> {
let pre = &version.pre; let pre = &version.pre;
if pre.is_empty() { 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); let is_pre_release = pre.starts_with(SNAPSHOT_DASH);
@@ -36,15 +44,11 @@ fn to_semver(version: &Version) -> Version {
pre.as_str() pre.as_str()
}; };
// Parse the build number to validate it, we do not need the actual number though. // Parse the build number to validate it, we do not need the actual number though.
build_number_str.parse::<usize>().unwrap_or_else(|_| { build_number_str.parse::<usize>().ok()?;
panic!(
"looks like Coco changed the version string format, but forgot to update this function"
);
});
// Return after checking the build number is valid // Return after checking the build number is valid
if !is_pre_release { 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 = { let pre = {
@@ -52,16 +56,23 @@ fn to_semver(version: &Version) -> Version {
Prerelease::new(&pre_str).unwrap_or_else(|e| panic!("invalid Prerelease: {}", e)) Prerelease::new(&pre_str).unwrap_or_else(|e| panic!("invalid Prerelease: {}", e))
}; };
Version { Some(SemVer {
major: version.major, major: version.major,
minor: version.minor, minor: version.minor,
patch: version.patch, patch: version.patch,
pre, pre,
build: BuildMetadata::EMPTY, 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(&not_semver)
}
pub(crate) fn custom_version_comparator(local: SemVer, remote_release: RemoteRelease) -> bool {
let remote = remote_release.version; let remote = remote_release.version;
let local_semver = to_semver(&local); let local_semver = to_semver(&local);
let remote_semver = to_semver(&remote); let remote_semver = to_semver(&remote);
@@ -88,8 +99,8 @@ mod tests {
fn test_try_into_semver_local_dev() { fn test_try_into_semver_local_dev() {
// Case: 0.8.0 => 0.8.0 // Case: 0.8.0 => 0.8.0
// Local development version without any pre-release or build metadata // Local development version without any pre-release or build metadata
let input = Version::parse("0.8.0").unwrap(); let input = SemVer::parse("0.8.0").unwrap();
let result = to_semver(&input); let result = to_semver(&input).unwrap();
assert_eq!(result.major, 0); assert_eq!(result.major, 0);
assert_eq!(result.minor, 8); assert_eq!(result.minor, 8);
@@ -103,8 +114,8 @@ mod tests {
fn test_try_into_semver_official_release() { fn test_try_into_semver_official_release() {
// Case: 0.8.0-<build num> => 0.8.0 // Case: 0.8.0-<build num> => 0.8.0
// Official release with build number in pre-release field // Official release with build number in pre-release field
let input = Version::parse("0.8.0-123").unwrap(); let input = SemVer::parse("0.8.0-123").unwrap();
let result = to_semver(&input); let result = to_semver(&input).unwrap();
assert_eq!(result.major, 0); assert_eq!(result.major, 0);
assert_eq!(result.minor, 8); assert_eq!(result.minor, 8);
@@ -118,8 +129,8 @@ mod tests {
fn test_try_into_semver_pre_release() { fn test_try_into_semver_pre_release() {
// Case: 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num> // Case: 0.9.0-SNAPSHOT-<build num> => 0.9.0-SNAPSHOT.<build num>
// Pre-release version with SNAPSHOT prefix // Pre-release version with SNAPSHOT prefix
let input = Version::parse("0.9.0-SNAPSHOT-456").unwrap(); let input = SemVer::parse("0.9.0-SNAPSHOT-456").unwrap();
let result = to_semver(&input); let result = to_semver(&input).unwrap();
assert_eq!(result.major, 0); assert_eq!(result.major, 0);
assert_eq!(result.minor, 9); assert_eq!(result.minor, 9);
@@ -132,8 +143,8 @@ mod tests {
#[test] #[test]
fn test_try_into_semver_official_release_different_version() { fn test_try_into_semver_official_release_different_version() {
// Test with different version numbers // Test with different version numbers
let input = Version::parse("1.2.3-9999").unwrap(); let input = SemVer::parse("1.2.3-9999").unwrap();
let result = to_semver(&input); let result = to_semver(&input).unwrap();
assert_eq!(result.major, 1); assert_eq!(result.major, 1);
assert_eq!(result.minor, 2); assert_eq!(result.minor, 2);
@@ -146,8 +157,8 @@ mod tests {
#[test] #[test]
fn test_try_into_semver_snapshot_different_version() { fn test_try_into_semver_snapshot_different_version() {
// Test SNAPSHOT with different version numbers // Test SNAPSHOT with different version numbers
let input = Version::parse("2.0.0-SNAPSHOT-777").unwrap(); let input = SemVer::parse("2.0.0-SNAPSHOT-777").unwrap();
let result = to_semver(&input); let result = to_semver(&input).unwrap();
assert_eq!(result.major, 2); assert_eq!(result.major, 2);
assert_eq!(result.minor, 0); assert_eq!(result.minor, 0);
@@ -158,28 +169,26 @@ mod tests {
} }
#[test] #[test]
#[should_panic(expected = "looks like Coco changed the version string format")]
fn test_try_into_semver_invalid_build_number() { fn test_try_into_semver_invalid_build_number() {
// Should panic when build number is not a valid number // Should panic when build number is not a valid number
let input = Version::parse("0.8.0-abc").unwrap(); let input = SemVer::parse("0.8.0-abc").unwrap();
to_semver(&input); assert!(to_semver(&input).is_none());
} }
#[test] #[test]
#[should_panic(expected = "looks like Coco changed the version string format")]
fn test_try_into_semver_invalid_snapshot_build_number() { fn test_try_into_semver_invalid_snapshot_build_number() {
// Should panic when SNAPSHOT build number is not a valid number // Should panic when SNAPSHOT build number is not a valid number
let input = Version::parse("0.9.0-SNAPSHOT-xyz").unwrap(); let input = SemVer::parse("0.9.0-SNAPSHOT-xyz").unwrap();
to_semver(&input); assert!(to_semver(&input).is_none());
} }
#[test] #[test]
fn test_custom_version_comparator() { fn test_custom_version_comparator() {
fn new_local(str: &str) -> Version { fn new_local(str: &str) -> SemVer {
Version::parse(str).unwrap() SemVer::parse(str).unwrap()
} }
fn new_remote_release(str: &str) -> RemoteRelease { fn new_remote_release(str: &str) -> RemoteRelease {
let version = Version::parse(str).unwrap(); let version = SemVer::parse(str).unwrap();
RemoteRelease { RemoteRelease {
version, version,

View File

@@ -5,7 +5,7 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import { parseSearchQuery } from "@/utils"; import { installExtensionError, parseSearchQuery } from "@/utils";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import SearchEmpty from "../Common/SearchEmpty"; import SearchEmpty from "../Common/SearchEmpty";
import ExtensionDetail from "./ExtensionDetail"; import ExtensionDetail from "./ExtensionDetail";
@@ -244,7 +244,7 @@ const ExtensionStore = ({ extensionId }: { extensionId?: string }) => {
"info" "info"
); );
} catch (error) { } catch (error) {
addError(String(error), "error"); installExtensionError(String(error));
} finally { } finally {
const { installingExtensions } = useSearchStore.getState(); const { installingExtensions } = useSearchStore.getState();

View File

@@ -1,5 +1,5 @@
import { FC, MouseEvent, useContext, useMemo, useState } from "react"; import { FC, MouseEvent, useContext, useMemo, useState } from "react";
import { useReactive } from "ahooks"; import { useMount, useReactive } from "ahooks";
import { ChevronRight, LoaderCircle } from "lucide-react"; import { ChevronRight, LoaderCircle } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { isArray, startCase, sortBy } from "lodash-es"; import { isArray, startCase, sortBy } from "lodash-es";
@@ -20,11 +20,12 @@ const Content = () => {
return rootState.extensions.map((item) => { return rootState.extensions.map((item) => {
const { id } = 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; level: number;
parentId?: ExtensionId; parentId?: ExtensionId;
parentDeveloper?: string; parentDeveloper?: string;
@@ -42,19 +43,8 @@ const subExtensionCommand: Partial<Record<ExtensionId, string>> = {
}; };
const Item: FC<ItemProps> = (props) => { const Item: FC<ItemProps> = (props) => {
const { const { extension, level, parentId, parentDeveloper, parentDisabled } = props;
id, const { id, icon, name, type, platforms, developer, enabled } = extension;
icon,
name,
type,
level,
platforms,
developer,
enabled,
parentId,
parentDeveloper,
parentDisabled,
} = props;
const { rootState } = useContext(ExtensionsContext); const { rootState } = useContext(ExtensionsContext);
const state = useReactive<ItemState>({ const state = useReactive<ItemState>({
loading: false, loading: false,
@@ -63,6 +53,18 @@ const Item: FC<ItemProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { disabledExtensions, setDisabledExtensions } = useExtensionsStore(); const { disabledExtensions, setDisabledExtensions } = useExtensionsStore();
const [selfDisabled, setSelfDisabled] = useState(!enabled); 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 = { const bundleId = {
developer: developer ?? parentDeveloper, developer: developer ?? parentDeveloper,
@@ -71,7 +73,7 @@ const Item: FC<ItemProps> = (props) => {
}; };
const hasSubExtensions = () => { const hasSubExtensions = () => {
const { commands, scripts, quicklinks } = props; const { commands, scripts, quicklinks } = extension;
if (subExtensionCommand[id]) { if (subExtensionCommand[id]) {
return true; return true;
@@ -87,7 +89,7 @@ const Item: FC<ItemProps> = (props) => {
const getSubExtensions = async () => { const getSubExtensions = async () => {
state.loading = true; state.loading = true;
const { commands, scripts, quicklinks } = props; const { commands, scripts, quicklinks } = extension;
let subExtensions: Extension[] = []; let subExtensions: Extension[] = [];
@@ -117,12 +119,16 @@ const Item: FC<ItemProps> = (props) => {
}; };
const isDisabled = useMemo(() => { const isDisabled = useMemo(() => {
if (!compatible) {
return true;
}
if (level === 1) { if (level === 1) {
return selfDisabled; return selfDisabled;
} }
return parentDisabled || selfDisabled; return parentDisabled || selfDisabled;
}, [parentDisabled, selfDisabled]); }, [parentDisabled, selfDisabled, compatible]);
const editable = useMemo(() => { const editable = useMemo(() => {
return ( return (
@@ -134,7 +140,7 @@ const Item: FC<ItemProps> = (props) => {
}, [type]); }, [type]);
const renderAlias = () => { const renderAlias = () => {
const { alias } = props; const { alias } = extension;
const handleChange = (value: string) => { const handleChange = (value: string) => {
platformAdapter.invokeBackend("set_extension_alias", { platformAdapter.invokeBackend("set_extension_alias", {
@@ -173,7 +179,7 @@ const Item: FC<ItemProps> = (props) => {
}; };
const renderHotkey = () => { const renderHotkey = () => {
const { hotkey } = props; const { hotkey } = extension;
const handleChange = (value: string) => { const handleChange = (value: string) => {
if (value) { if (value) {
@@ -246,7 +252,7 @@ const Item: FC<ItemProps> = (props) => {
return ( return (
<div <div
className={clsx("flex items-center justify-end", { className={clsx("flex items-center justify-end", {
"opacity-50 pointer-events-none": parentDisabled, "opacity-50 pointer-events-none": !compatible || parentDisabled,
})} })}
> >
<SettingsToggle <SettingsToggle
@@ -294,7 +300,7 @@ const Item: FC<ItemProps> = (props) => {
<div <div
className="flex items-center justify-between gap-2 h-8" className="flex items-center justify-between gap-2 h-8"
onClick={() => { onClick={() => {
rootState.activeExtension = props; rootState.activeExtension = extension;
}} }}
> >
<div <div
@@ -356,7 +362,7 @@ const Item: FC<ItemProps> = (props) => {
return ( return (
<Item <Item
key={item.id} key={item.id}
{...item} extension={item}
level={level + 1} level={level + 1}
parentId={id} parentId={id}
parentDeveloper={developer} parentDeveloper={developer}

View File

@@ -1,4 +1,4 @@
import { useContext } from "react"; import { useContext, useState } from "react";
import { ExtensionsContext } from "../.."; import { ExtensionsContext } from "../..";
import Applications from "./Applications"; import Applications from "./Applications";
@@ -8,11 +8,12 @@ import SharedAi from "./SharedAi";
import AiOverview from "./AiOverview"; import AiOverview from "./AiOverview";
import Calculator from "./Calculator"; import Calculator from "./Calculator";
import FileSearch from "./FileSearch"; import FileSearch from "./FileSearch";
import { Ellipsis } from "lucide-react"; import { Ellipsis, Info } from "lucide-react";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import platformAdapter from "@/utils/platformAdapter"; import platformAdapter from "@/utils/platformAdapter";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
const Details = () => { const Details = () => {
const { rootState } = useContext(ExtensionsContext); const { rootState } = useContext(ExtensionsContext);
@@ -33,6 +34,23 @@ const Details = () => {
}); });
const { t } = useTranslation(); 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 = () => { const renderContent = () => {
if (!rootState.activeExtension) return; if (!rootState.activeExtension) return;
@@ -77,7 +95,7 @@ const Details = () => {
return ( return (
<div className="flex-1 h-full pr-4 pb-4 overflow-auto"> <div className="flex-1 h-full pr-4 pb-4 overflow-auto">
<div className="flex items-start justify-between gap-4 mb-4"> <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} {rootState.activeExtension?.name}
</h2> </h2>
@@ -130,6 +148,16 @@ const Details = () => {
)} )}
</div> </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 className="text-sm">{renderContent()}</div>
</div> </div>
); );

View File

@@ -13,6 +13,7 @@ import Details from "./components/Details";
import { useExtensionsStore } from "@/stores/extensionsStore"; import { useExtensionsStore } from "@/stores/extensionsStore";
import SettingsInput from "../SettingsInput"; import SettingsInput from "../SettingsInput";
import { useAppStore } from "@/stores/appStore"; import { useAppStore } from "@/stores/appStore";
import { installExtensionError } from "@/utils";
export type ExtensionId = LiteralUnion< export type ExtensionId = LiteralUnion<
| "Applications" | "Applications"
@@ -32,7 +33,9 @@ type ExtensionType =
| "setting" | "setting"
| "calculator" | "calculator"
| "command" | "command"
| "ai_extension"; | "ai_extension"
| "view"
| "unknown";
export type ExtensionPlatform = "windows" | "macos" | "linux"; export type ExtensionPlatform = "windows" | "macos" | "linux";
@@ -63,9 +66,9 @@ export interface ExtensionPermission {
} }
export interface ViewExtensionUISettings { export interface ViewExtensionUISettings {
search_bar: boolean, search_bar: boolean;
filter_bar: boolean, filter_bar: boolean;
footer: boolean, footer: boolean;
} }
export interface Extension { export interface Extension {
@@ -143,6 +146,8 @@ export const Extensions = () => {
} }
); );
console.log("extensions", cloneDeep(extensions));
state.extensions = sortBy(extensions, ["name"]); state.extensions = sortBy(extensions, ["name"]);
if (configId) { if (configId) {
@@ -228,21 +233,7 @@ export const Extensions = () => {
"info" "info"
); );
} catch (error) { } catch (error) {
const errorMessage = String(error); installExtensionError(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"));
}
} }
}} }}
> >

View File

@@ -274,6 +274,7 @@ export default function GeneralSettings() {
return ( return (
<button <button
key={value}
onClick={() => { onClick={() => {
setWindowMode(value); setWindowMode(value);
}} }}

View File

@@ -222,9 +222,11 @@
"importSuccess": "Extension imported successfully.", "importSuccess": "Extension imported successfully.",
"importFailed": "No valid extension found in the selected folder. Please check the folder structure.", "importFailed": "No valid extension found in the selected folder. Please check the folder structure.",
"extensionAlreadyImported": "Extension already imported. Please remove it first.", "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", "uninstall": "Uninstall",
"uninstallSuccess": "Uninstalled successfully" "uninstallSuccess": "Uninstalled successfully",
"incompatible": "Extension cannot run on the current version. Please upgrade Coco App."
}, },
"application": { "application": {
"title": "Applications", "title": "Applications",

View File

@@ -222,9 +222,11 @@
"importSuccess": "插件导入成功。", "importSuccess": "插件导入成功。",
"importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。", "importFailed": "未在该目录中找到有效的插件,请检查目录结构是否正确。",
"extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。", "extensionAlreadyImported": "插件已存在,无法重复导入。请先将其删除后再尝试。",
"incompatibleExtension": "此插件与当前操作系统不兼容。", "platformIncompatibleExtension": "此插件与当前操作系统不兼容。",
"appIncompatibleExtension": "安装失败!该插件与当前 Coco App 版本不兼容,请升级后重试。",
"uninstall": "卸载", "uninstall": "卸载",
"uninstallSuccess": "卸载成功" "uninstallSuccess": "卸载成功",
"incompatible": "扩展无法在当前版本中运行,请升级 Coco App。"
}, },
"application": { "application": {
"title": "应用程序", "title": "应用程序",

View File

@@ -8,6 +8,7 @@ import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
import { useChatStore } from "@/stores/chatStore"; import { useChatStore } from "@/stores/chatStore";
import { getCurrentWindowService } from "@/commands/windowService"; import { getCurrentWindowService } from "@/commands/windowService";
import { useSearchStore } from "@/stores/searchStore"; import { useSearchStore } from "@/stores/searchStore";
import i18next from "i18next";
// 1 // 1
export async function copyToClipboard(text: string) { export async function copyToClipboard(text: string) {
@@ -326,3 +327,23 @@ export const visibleFooterBar = () => {
return ui?.footer ?? true; 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));
};