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 (