From 3e0839f3da93cd7a01f15cae671bcd44f5672de0 Mon Sep 17 00:00:00 2001 From: SteveLauC Date: Sun, 2 Nov 2025 10:59:29 +0800 Subject: [PATCH] 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> --- docs/content.en/docs/release-notes/_index.md | 1 + .../built_in/application/with_feature.rs | 1 + src-tauri/src/extension/mod.rs | 58 +++++++ src-tauri/src/extension/third_party/check.rs | 24 +++ .../third_party/install/local_extension.rs | 7 +- .../src/extension/third_party/install/mod.rs | 54 +++++- .../extension/third_party/install/store.rs | 7 +- src-tauri/src/extension/third_party/mod.rs | 163 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/util/version.rs | 73 ++++---- src/components/Search/ExtensionStore.tsx | 4 +- .../Extensions/components/Content/index.tsx | 54 +++--- .../Extensions/components/Details/index.tsx | 34 +++- src/components/Settings/Extensions/index.tsx | 29 ++-- src/components/Settings/GeneralSettings.tsx | 1 + src/locales/en/translation.json | 6 +- src/locales/zh/translation.json | 6 +- src/utils/index.ts | 21 +++ 18 files changed, 449 insertions(+), 95 deletions(-) diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index f649fd4f..b47f2685 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -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 diff --git a/src-tauri/src/extension/built_in/application/with_feature.rs b/src-tauri/src/extension/built_in/application/with_feature.rs index 06107fd0..c90b280c 100644 --- a/src-tauri/src/extension/built_in/application/with_feature.rs +++ b/src-tauri/src/extension/built_in/application/with_feature.rs @@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result, name, platforms: None, developer: None, + minimum_coco_version: None, // Leave it empty as it won't be used description: String::new(), icon: icon_path, diff --git a/src-tauri/src/extension/mod.rs b/src-tauri/src/extension/mod.rs index 116d87db..3366e731 100644 --- a/src-tauri/src/extension/mod.rs +++ b/src-tauri/src/extension/mod.rs @@ -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, + /// 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, + /* * 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, D::Error> +where + D: serde::Deserializer<'de>, +{ + let version_str: Option = 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`, 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, diff --git a/src-tauri/src/extension/third_party/check.rs b/src-tauri/src/extension/third_party/check.rs index 5182a538..962408c5 100644 --- a/src-tauri/src/extension/third_party/check.rs +++ b/src-tauri/src/extension/third_party/check.rs @@ -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] diff --git a/src-tauri/src/extension/third_party/install/local_extension.rs b/src-tauri/src/extension/third_party/install/local_extension.rs index b488c2d0..e83877cb 100644 --- a/src-tauri/src/extension/third_party/install/local_extension.rs +++ b/src-tauri/src/extension/third_party/install/local_extension.rs @@ -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 */ diff --git a/src-tauri/src/extension/third_party/install/mod.rs b/src-tauri/src/extension/third_party/install/mod.rs index 99637e75..01e6d263 100644 --- a/src-tauri/src/extension/third_party/install/mod.rs +++ b/src-tauri/src/extension/third_party/install/mod.rs @@ -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 { + 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, diff --git a/src-tauri/src/extension/third_party/install/store.rs b/src-tauri/src/extension/third_party/install/store.rs index 9a6c98e7..37eeea3f 100644 --- a/src-tauri/src/extension/third_party/install/store.rs +++ b/src-tauri/src/extension/third_party/install/store.rs @@ -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()); } } diff --git a/src-tauri/src/extension/third_party/mod.rs b/src-tauri/src/extension/third_party/mod.rs index 46047083..ebf6072f 100644 --- a/src-tauri/src/extension/third_party/mod.rs +++ b/src-tauri/src/extension/third_party/mod.rs @@ -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::(&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 = { + 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::(&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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c4c5e20..3afe26a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/util/version.rs b/src-tauri/src/util/version.rs index a6a3e8be..9e35e5c0 100644 --- a/src-tauri/src/util/version.rs +++ b/src-tauri/src/util/version.rs @@ -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 = 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- => 0.9.0-SNAPSHOT. /// /// A pre-release of 0.9.0 -fn to_semver(version: &Version) -> Version { +fn to_semver(version: &SemVer) -> Option { 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::().unwrap_or_else(|_| { - panic!( - "looks like Coco changed the version string format, but forgot to update this function" - ); - }); + build_number_str.parse::().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 { + 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- => 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- => 0.9.0-SNAPSHOT. // 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, diff --git a/src/components/Search/ExtensionStore.tsx b/src/components/Search/ExtensionStore.tsx index ef58a914..a454847f 100644 --- a/src/components/Search/ExtensionStore.tsx +++ b/src/components/Search/ExtensionStore.tsx @@ -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(); diff --git a/src/components/Settings/Extensions/components/Content/index.tsx b/src/components/Settings/Extensions/components/Content/index.tsx index 611471c5..9e8a08dd 100644 --- a/src/components/Settings/Extensions/components/Content/index.tsx +++ b/src/components/Settings/Extensions/components/Content/index.tsx @@ -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 ; + return ; }); }; -interface ItemProps extends Extension { +interface ItemProps { + extension: Extension; level: number; parentId?: ExtensionId; parentDeveloper?: string; @@ -42,19 +43,8 @@ const subExtensionCommand: Partial> = { }; const Item: FC = (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({ loading: false, @@ -63,6 +53,18 @@ const Item: FC = (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( + "is_extension_compatible", + { + extension, + } + ); + + setCompatible(compatible); + }); const bundleId = { developer: developer ?? parentDeveloper, @@ -71,7 +73,7 @@ const Item: FC = (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 = (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 = (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 = (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 = (props) => { }; const renderHotkey = () => { - const { hotkey } = props; + const { hotkey } = extension; const handleChange = (value: string) => { if (value) { @@ -246,7 +252,7 @@ const Item: FC = (props) => { return (
= (props) => {
{ - rootState.activeExtension = props; + rootState.activeExtension = extension; }} >
= (props) => { return ( { 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( + "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 (
-

+

{rootState.activeExtension?.name}

@@ -130,6 +148,16 @@ const Details = () => { )}
+ {!compatible && ( +
+ + + + {t("settings.extensions.hints.incompatible")} + +
+ )} +
{renderContent()}
); diff --git a/src/components/Settings/Extensions/index.tsx b/src/components/Settings/Extensions/index.tsx index 4f2646ad..b3682886 100644 --- a/src/components/Settings/Extensions/index.tsx +++ b/src/components/Settings/Extensions/index.tsx @@ -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)); } }} > diff --git a/src/components/Settings/GeneralSettings.tsx b/src/components/Settings/GeneralSettings.tsx index 73cdf99e..970ef8ed 100644 --- a/src/components/Settings/GeneralSettings.tsx +++ b/src/components/Settings/GeneralSettings.tsx @@ -274,6 +274,7 @@ export default function GeneralSettings() { return (