Skip to content

Commit 3e0839f

Browse files
SteveLauCayangweb
andauthored
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>
1 parent bd61faf commit 3e0839f

18 files changed

Lines changed: 449 additions & 95 deletions

File tree

docs/content.en/docs/release-notes/_index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ feat: return sub-exts when extension type exts themselves are matched #928
2323
feat: open quick ai with modifier key + enter #939
2424
feat: allow navigate back when cursor is at the beginning #940
2525
feat: add compact mode for window #947
26+
feat(extension compatibility): minimum_coco_version #946
2627

2728
### 🐛 Bug fix
2829

src-tauri/src/extension/built_in/application/with_feature.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,6 +1227,7 @@ pub async fn get_app_list(tauri_app_handle: AppHandle) -> Result<Vec<Extension>,
12271227
name,
12281228
platforms: None,
12291229
developer: None,
1230+
minimum_coco_version: None,
12301231
// Leave it empty as it won't be used
12311232
description: String::new(),
12321233
icon: icon_path,

src-tauri/src/extension/mod.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,28 @@ use crate::common::document::ExtensionOnOpenedType;
77
use crate::common::document::OnOpened;
88
use crate::common::register::SearchSourceRegistry;
99
use crate::util::platform::Platform;
10+
use crate::util::version::COCO_VERSION;
11+
use crate::util::version::parse_coco_semver;
1012
use anyhow::Context;
1113
use bitflags::bitflags;
1214
use borrowme::{Borrow, ToOwned};
1315
use derive_more::Display;
1416
use indexmap::IndexMap;
17+
use semver::Version as SemVer;
1518
use serde::Deserialize;
1619
use serde::Serialize;
1720
use serde_json::Value as Json;
1821
use std::collections::HashMap;
1922
use std::collections::HashSet;
23+
use std::ops::Deref;
2024
use std::path::Path;
2125
use tauri::{AppHandle, Manager};
2226
use third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
2327

2428
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
2529
const PLUGIN_JSON_FILE_NAME: &str = "plugin.json";
2630
const ASSETS_DIRECTORY_FILE_NAME: &str = "assets";
31+
const PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION: &str = "minimum_coco_version";
2732

2833
fn default_true() -> bool {
2934
true
@@ -119,6 +124,16 @@ pub struct Extension {
119124
/// Permission that this extension requires.
120125
permission: Option<ExtensionPermission>,
121126

127+
/// The version of Coco app that this extension requires.
128+
///
129+
/// If not set, then this extension is compatible with all versions of Coco app.
130+
///
131+
/// It is only for third-party extensions. Built-in extensions should always
132+
/// set this field to `None`.
133+
#[serde(deserialize_with = "deserialize_coco_semver")]
134+
#[serde(default)] // None if this field is missing
135+
minimum_coco_version: Option<SemVer>,
136+
122137
/*
123138
* The following fields are currently useless to us but are needed by our
124139
* extension store.
@@ -292,6 +307,9 @@ impl Extension {
292307

293308
Some(on_opened)
294309
}
310+
ExtensionType::Unknown => {
311+
unreachable!("Extensions of type [Unknown] should never be opened")
312+
}
295313
}
296314
}
297315

@@ -366,6 +384,26 @@ impl Extension {
366384
}
367385
}
368386

387+
/// Deserialize Coco SemVer from a string.
388+
///
389+
/// This function adapts `parse_coco_semver` to work with serde's `deserialize_with`
390+
/// attribute.
391+
fn deserialize_coco_semver<'de, D>(deserializer: D) -> Result<Option<SemVer>, D::Error>
392+
where
393+
D: serde::Deserializer<'de>,
394+
{
395+
let version_str: Option<String> = Option::deserialize(deserializer)?;
396+
let Some(version_str) = version_str else {
397+
return Ok(None);
398+
};
399+
400+
let Some(semver) = parse_coco_semver(&version_str) else {
401+
return Err(serde::de::Error::custom("version string format is invalid"));
402+
};
403+
404+
Ok(Some(semver))
405+
}
406+
369407
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
370408
pub(crate) struct CommandAction {
371409
pub(crate) exec: String,
@@ -569,6 +607,10 @@ pub enum ExtensionType {
569607
AiExtension,
570608
#[display("View")]
571609
View,
610+
/// Add this variant for better compatibility: Future versions of Coco may
611+
/// add new extension types that older versions of Coco are not aware of.
612+
#[display("Unknown")]
613+
Unknown,
572614
}
573615

574616
impl ExtensionType {
@@ -816,6 +858,22 @@ pub(crate) async fn init_extensions(tauri_app_handle: &AppHandle) -> Result<(),
816858
Ok(())
817859
}
818860

861+
/// Is `extension` compatible with the current running Coco app?
862+
///
863+
/// It is defined as a tauri command rather than an associated function because
864+
/// it will be used in frontend code as well.
865+
///
866+
/// Async tauri commands are required to return `Result<T, E>`, this function
867+
/// only needs to return a boolean, so it is not marked async.
868+
#[tauri::command]
869+
pub(crate) fn is_extension_compatible(extension: Extension) -> bool {
870+
let Some(ref minimum_coco_version) = extension.minimum_coco_version else {
871+
return true;
872+
};
873+
874+
COCO_VERSION.deref() >= minimum_coco_version
875+
}
876+
819877
#[tauri::command]
820878
pub(crate) async fn enable_extension(
821879
tauri_app_handle: AppHandle,

src-tauri/src/extension/third_party/check.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
1515
use crate::extension::Extension;
1616
use crate::extension::ExtensionType;
17+
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
1718
use crate::util::platform::Platform;
1819
use std::collections::HashSet;
1920

@@ -179,6 +180,13 @@ fn check_sub_extension_only(
179180
}
180181
}
181182

183+
if sub_extension.minimum_coco_version.is_some() {
184+
return Err(format!(
185+
"invalid sub-extension [{}-{}]: [{}] cannot be set for sub-extensions",
186+
extension_id, sub_extension.id, PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
187+
));
188+
}
189+
182190
Ok(())
183191
}
184192

@@ -278,6 +286,7 @@ mod tests {
278286
ui: None,
279287
permission: None,
280288
settings: None,
289+
minimum_coco_version: None,
281290
screenshots: None,
282291
url: None,
283292
version: None,
@@ -541,6 +550,21 @@ mod tests {
541550
"fields [commands/scripts/quicklinks/views] should not be set in sub-extensions"
542551
));
543552
}
553+
554+
#[test]
555+
fn test_sub_extension_cannot_set_minimum_coco_version() {
556+
let mut extension = create_basic_extension("test-group", ExtensionType::Group);
557+
let mut sub_cmd = create_basic_extension("sub-cmd", ExtensionType::Command);
558+
sub_cmd.minimum_coco_version = Some(semver::Version::new(0, 8, 0));
559+
extension.commands = Some(vec![sub_cmd]);
560+
561+
let result = general_check(&extension);
562+
assert!(result.is_err());
563+
assert!(result.unwrap_err().contains(&format!(
564+
"[{}] cannot be set for sub-extensions",
565+
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
566+
)));
567+
}
544568
/* Test check_sub_extension_only */
545569

546570
#[test]

src-tauri/src/extension/third_party/install/local_extension.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use super::check_compatibility_via_mcv;
12
use crate::extension::PLUGIN_JSON_FILE_NAME;
23
use crate::extension::third_party::check::general_check;
34
use crate::extension::third_party::install::{
@@ -79,6 +80,10 @@ pub(crate) async fn install_local_extension(
7980
let mut extension_json: Json =
8081
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
8182

83+
if !check_compatibility_via_mcv(&extension_json)? {
84+
return Err("app_incompatible".into());
85+
}
86+
8287
// Set the main extension ID to the directory name
8388
let extension_obj = extension_json
8489
.as_object_mut()
@@ -158,7 +163,7 @@ pub(crate) async fn install_local_extension(
158163
//
159164
// This is definitely error-prone, but we have to do this until we have
160165
// structured error type
161-
return Err("incompatible".into());
166+
return Err("platform_incompatible".into());
162167
}
163168
}
164169
/* Check ends here */

src-tauri/src/extension/third_party/install/mod.rs

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@
44
//! # How
55
//!
66
//! Technically, installing an extension involves the following steps. The order
7-
//! may vary between implementations.
7+
//! varies between 2 implementations.
88
//!
99
//! 1. Check if it is already installed, if so, return
1010
//!
11-
//! 2. Correct the `plugin.json` JSON if it does not conform to our `struct
11+
//! 2. Check if it is compatible by inspecting the "minimum_coco_version"
12+
//! field. If it is incompatible, reject and error out.
13+
//!
14+
//! This should be done before convert `plugin.json` JSON to `struct Extension`
15+
//! as the definition of `struct Extension` could change in the future, in this
16+
//! case, we want to tell users that "it is an incompatible extension" rather
17+
//! than "this extension is invalid".
18+
//!
19+
//! 3. Correct the `plugin.json` JSON if it does not conform to our `struct
1220
//! Extension` definition. This can happen because the JSON written by
1321
//! developers is in a simplified form for a better developer experience.
1422
//!
15-
//! 3. Validate the corrected `plugin.json`
23+
//! 4. Validate the corrected `plugin.json`
1624
//! 1. misc checks
1725
//! 2. Platform compatibility check
1826
//!
19-
//! 4. Write the extension files to the corresponding location
27+
//! 5. Write the extension files to the corresponding location
2028
//!
2129
//! * developer directory
2230
//! * extension directory
@@ -25,25 +33,29 @@
2533
//! * plugin.json file
2634
//! * View pages if exist
2735
//!
28-
//! 5. If this extension contains any View extensions, call `convert_page()`
36+
//! 6. If this extension contains any View extensions, call `convert_page()`
2937
//! on them to make them loadable by Tauri/webview.
3038
//!
3139
//! See `convert_page()` for more info.
3240
//!
33-
//! 6. Canonicalize `Extension.icon` and `Extension.page` fields if they are
41+
//! 7. Canonicalize `Extension.icon` and `Extension.page` fields if they are
3442
//! relative paths
3543
//!
3644
//! * icon: relative to the `assets` directory
3745
//! * page: relative to the extension root directory
3846
//!
39-
//! 7. Add the extension to the in-memory extension list.
47+
//! 8. Add the extension to the in-memory extension list.
4048
4149
pub(crate) mod local_extension;
4250
pub(crate) mod store;
4351

4452
use crate::extension::Extension;
4553
use crate::extension::ExtensionType;
54+
use crate::extension::PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION;
4655
use crate::util::platform::Platform;
56+
use crate::util::version::{COCO_VERSION, parse_coco_semver};
57+
use serde_json::Value as Json;
58+
use std::ops::Deref;
4759
use std::path::Path;
4860
use std::path::PathBuf;
4961

@@ -287,6 +299,33 @@ async fn view_extension_convert_pages(
287299
Ok(())
288300
}
289301

302+
/// Inspect the "minimum_coco_version" field and see if this extension is
303+
/// compatible with the current Coco app.
304+
fn check_compatibility_via_mcv(plugin_json: &Json) -> Result<bool, String> {
305+
let Some(mcv_json) = plugin_json.get(PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION) else {
306+
return Ok(true);
307+
};
308+
if mcv_json == &Json::Null {
309+
return Ok(true);
310+
}
311+
312+
let Some(mcv_str) = mcv_json.as_str() else {
313+
return Err(format!(
314+
"invalid extension: field [{}] should be a string",
315+
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
316+
));
317+
};
318+
319+
let Some(mcv) = parse_coco_semver(mcv_str) else {
320+
return Err(format!(
321+
"invalid extension: [{}] is not a valid version string",
322+
PLUGIN_JSON_FIELD_MINIMUM_COCO_VERSION
323+
));
324+
};
325+
326+
Ok(COCO_VERSION.deref() >= &mcv)
327+
}
328+
290329
#[cfg(test)]
291330
mod tests {
292331
use super::*;
@@ -319,6 +358,7 @@ mod tests {
319358
settings: None,
320359
page: None,
321360
ui: None,
361+
minimum_coco_version: None,
322362
permission: None,
323363
screenshots: None,
324364
url: None,

src-tauri/src/extension/third_party/install/store.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Extension store related stuff.
22
33
use super::super::LOCAL_QUERY_SOURCE_TYPE;
4+
use super::check_compatibility_via_mcv;
45
use super::is_extension_installed;
56
use crate::common::document::DataSourceReference;
67
use crate::common::document::Document;
@@ -259,6 +260,10 @@ pub(crate) async fn install_extension_from_store(
259260
let mut extension: Json = serde_json::from_str(&plugin_json_content)
260261
.map_err(|e| format!("Failed to parse plugin.json: {}", e))?;
261262

263+
if !check_compatibility_via_mcv(&extension)? {
264+
return Err("app_incompatible".into());
265+
}
266+
262267
let mut_ref_to_developer_object: &mut Json = extension
263268
.as_object_mut()
264269
.expect("plugin.json should be an object")
@@ -308,7 +313,7 @@ pub(crate) async fn install_extension_from_store(
308313
let current_platform = Platform::current();
309314
if let Some(ref platforms) = extension.platforms {
310315
if !platforms.contains(&current_platform) {
311-
return Err("this extension is not compatible with your OS".into());
316+
return Err("platform_incompatible".into());
312317
}
313318
}
314319

0 commit comments

Comments
 (0)