From b3f68697ce9cc367c8ef89ef4e116e0b57f4617c Mon Sep 17 00:00:00 2001 From: SteveLauC Date: Thu, 26 Jun 2025 18:40:33 +0800 Subject: [PATCH] feat: impl extension store (#699) Implements extension store so that users can install extensions from a GUI interface --------- Co-authored-by: ayang <473033518@qq.com> --- docs/content.en/docs/release-notes/_index.md | 1 + package.json | 1 + pnpm-lock.yaml | 8 + src-tauri/Cargo.lock | 201 +++++++++- src-tauri/Cargo.toml | 5 +- src-tauri/src/common/document.rs | 8 +- .../src/extension/built_in/ai_overview.rs | 2 +- .../src/extension/built_in/application/mod.rs | 2 +- .../built_in/application/with_feature.rs | 7 +- .../src/extension/built_in/calculator.rs | 2 +- src-tauri/src/extension/built_in/mod.rs | 2 +- .../src/extension/built_in/quick_ai_access.rs | 2 +- src-tauri/src/extension/mod.rs | 204 ++++++++-- src-tauri/src/extension/store.rs | 358 ++++++++++++++++++ src-tauri/src/extension/third_party.rs | 113 +++--- src-tauri/src/lib.rs | 6 +- src/components/Common/DeleteDialog.tsx | 119 ++++++ src/components/Common/SearchEmpty.tsx | 118 ++++++ src/components/Common/UI/Footer.tsx | 133 +++++-- src/components/Common/UI/NoResults.tsx | 8 +- src/components/Search/AssistantManager.tsx | 85 ++++- src/components/Search/ContextMenu.tsx | 126 ++++-- src/components/Search/DocumentList.tsx | 13 +- src/components/Search/ExtensionDetail.tsx | 175 +++++++++ src/components/Search/ExtensionStore.tsx | 338 +++++++++++++++++ src/components/Search/InputBox.tsx | 25 +- src/components/Search/InputControls.tsx | 4 +- src/components/Search/Search.tsx | 5 +- src/components/Search/SearchIcons.tsx | 25 +- .../Extensions/components/Content/index.tsx | 22 +- .../components/Details/Application/index.tsx | 4 +- .../Extensions/components/Details/index.tsx | 6 +- src/components/Settings/Extensions/index.tsx | 133 ++++++- src/components/Settings/SettingsInput.tsx | 6 +- src/hooks/useKeyboardHandlers.ts | 43 ++- src/hooks/useScript.ts | 2 +- src/locales/en/translation.json | 38 +- src/locales/zh/translation.json | 38 +- src/pages/settings/index.tsx | 16 +- src/routes/layout.tsx | 5 +- src/stores/extensionsStore.ts | 5 + src/stores/searchStore.ts | 36 ++ src/types/platform.ts | 4 + src/utils/tauriAdapter.ts | 4 +- 44 files changed, 2196 insertions(+), 262 deletions(-) create mode 100644 src-tauri/src/extension/store.rs create mode 100644 src/components/Common/DeleteDialog.tsx create mode 100644 src/components/Common/SearchEmpty.tsx create mode 100644 src/components/Search/ExtensionDetail.tsx create mode 100644 src/components/Search/ExtensionStore.tsx diff --git a/docs/content.en/docs/release-notes/_index.md b/docs/content.en/docs/release-notes/_index.md index b432ad52..9542de37 100644 --- a/docs/content.en/docs/release-notes/_index.md +++ b/docs/content.en/docs/release-notes/_index.md @@ -15,6 +15,7 @@ Information about release notes of Coco Server is provided here. - feat: support `Tab` and `Enter` for delete dialog buttons #700 - feat: add check for updates #701 +- feat: impl extension store #699 ### 🐛 Bug fix diff --git a/package.json b/package.json index 0c7625b7..2c1e39c1 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "tailwind-merge": "^3.3.1", "tauri-plugin-fs-pro-api": "^2.4.0", "tauri-plugin-macos-permissions-api": "^2.3.0", "tauri-plugin-screenshots-api": "^2.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bedc14e2..c5fe7499 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: remark-math: specifier: ^6.0.0 version: 6.0.0 + tailwind-merge: + specifier: ^3.3.1 + version: 3.3.1 tauri-plugin-fs-pro-api: specifier: ^2.4.0 version: 2.4.0 @@ -3447,6 +3450,9 @@ packages: tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tailwind-merge@3.3.1: + resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwindcss@3.4.17: resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} engines: {node: '>=14.0.0'} @@ -7248,6 +7254,8 @@ snapshots: tabbable@6.2.0: {} + tailwind-merge@3.3.1: {} + tailwindcss@3.4.17: dependencies: '@alloc/quick-lru': 5.2.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2b8b9659..b12b8636 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -654,6 +665,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -814,6 +844,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "3.2.25" @@ -906,6 +946,7 @@ dependencies = [ "tungstenite 0.24.0", "url", "walkdir", + "zip 4.0.0", ] [[package]] @@ -1013,6 +1054,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -1285,6 +1332,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "deranged" version = "0.4.0" @@ -1348,6 +1401,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1790,6 +1844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -2527,6 +2582,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hostname" version = "0.3.1" @@ -2970,6 +3034,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3235,6 +3308,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "liblzma" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0" +dependencies = [ + "liblzma-sys", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libredox" version = "0.1.3" @@ -3246,6 +3339,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -4293,6 +4395,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -5461,6 +5573,7 @@ version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ + "indexmap 2.9.0", "itoa 1.0.15", "memchr", "ryu", @@ -6446,7 +6559,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.59.0", - "zip", + "zip 2.6.1", ] [[package]] @@ -8471,6 +8584,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] [[package]] name = "zerotrie" @@ -8518,6 +8645,78 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.2", + "hmac", + "indexmap 2.9.0", + "liblzma", + "memchr", + "pbkdf2", + "sha1", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" + +[[package]] +name = "zopfli" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ad6af45..4f803750 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -49,7 +49,7 @@ tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } # Need `arbitrary_precision` feature to support storing u128 # see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128 -serde_json = { version = "1", features = ["arbitrary_precision"] } +serde_json = { version = "1", features = ["arbitrary_precision", "preserve_order"] } tauri-plugin-http = "2" tauri-plugin-websocket = "2" tauri-plugin-deep-link = "2.0.0" @@ -83,7 +83,6 @@ walkdir = "2" log = "0.4" strsim = "0.10" futures-util = "0.3.31" -url = "2.5.2" http = "1.1.0" tungstenite = "0.24.0" tokio-util = "0.7.14" @@ -101,6 +100,8 @@ regex = "1.11.1" borrowme = "0.0.15" tauri-plugin-opener = "2" async-recursion = "1.1.1" +zip = "4.0.0" +url = "2.5.2" [target."cfg(target_os = \"macos\")".dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } diff --git a/src-tauri/src/common/document.rs b/src-tauri/src/common/document.rs index 183d4189..4185014b 100644 --- a/src-tauri/src/common/document.rs +++ b/src-tauri/src/common/document.rs @@ -51,7 +51,9 @@ impl OnOpened { const WHITESPACE: &str = " "; let mut ret = action.exec.clone(); ret.push_str(WHITESPACE); - ret.push_str(action.args.join(WHITESPACE).as_str()); + if let Some(ref args) = action.args { + ret.push_str(args.join(WHITESPACE).as_str()); + } ret } @@ -80,7 +82,9 @@ pub(crate) async fn open(on_opened: OnOpened) -> Result<(), String> { } OnOpened::Command { action } => { let mut cmd = Command::new(action.exec); - cmd.args(action.args); + if let Some(args) = action.args { + cmd.args(args); + } let output = cmd.output().map_err(|e| e.to_string())?; if !output.status.success() { return Err(format!( diff --git a/src-tauri/src/extension/built_in/ai_overview.rs b/src-tauri/src/extension/built_in/ai_overview.rs index 5ae471be..aa22523f 100644 --- a/src-tauri/src/extension/built_in/ai_overview.rs +++ b/src-tauri/src/extension/built_in/ai_overview.rs @@ -4,7 +4,7 @@ pub(super) const EXTENSION_ID: &str = "AIOverview"; pub(crate) const PLUGIN_JSON_FILE: &str = r#" { "id": "AIOverview", - "title": "AI Overview", + "name": "AI Overview", "description": "...", "icon": "font_a-AIOverview", "type": "ai_extension", diff --git a/src-tauri/src/extension/built_in/application/mod.rs b/src-tauri/src/extension/built_in/application/mod.rs index 1833fbdc..e0d81477 100644 --- a/src-tauri/src/extension/built_in/application/mod.rs +++ b/src-tauri/src/extension/built_in/application/mod.rs @@ -39,7 +39,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#" { "id": "Applications", "platforms": ["macos", "linux", "windows"], - "title": "Applications", + "name": "Applications", "description": "Application search", "icon": "font_Application", "type": "group", 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 25d5e27e..149fc7c9 100644 --- a/src-tauri/src/extension/built_in/application/with_feature.rs +++ b/src-tauri/src/extension/built_in/application/with_feature.rs @@ -1038,9 +1038,9 @@ pub async fn get_app_list( let app_entry = Extension { id: path, - title: name, + name, platforms: None, - author: None, + developer: None, // Leave it empty as it won't be used description: String::new(), icon: icon_path, @@ -1054,6 +1054,9 @@ pub async fn get_app_list( hotkey, enabled, settings: None, + screenshots: None, + url: None, + version: None, }; app_entries.push(app_entry); diff --git a/src-tauri/src/extension/built_in/calculator.rs b/src-tauri/src/extension/built_in/calculator.rs index 89582a3c..7bdf8462 100644 --- a/src-tauri/src/extension/built_in/calculator.rs +++ b/src-tauri/src/extension/built_in/calculator.rs @@ -17,7 +17,7 @@ pub(crate) const DATA_SOURCE_ID: &str = "Calculator"; pub(crate) const PLUGIN_JSON_FILE: &str = r#" { "id": "Calculator", - "title": "Calculator", + "name": "Calculator", "platforms": ["macos", "linux", "windows"], "description": "...", "icon": "font_Calculator", diff --git a/src-tauri/src/extension/built_in/mod.rs b/src-tauri/src/extension/built_in/mod.rs index 134ab5a0..7d4d45f5 100644 --- a/src-tauri/src/extension/built_in/mod.rs +++ b/src-tauri/src/extension/built_in/mod.rs @@ -203,7 +203,7 @@ pub(super) async fn init_built_in_extension( } pub(crate) fn is_extension_built_in(bundle_id: &ExtensionBundleIdBorrowed<'_>) -> bool { - bundle_id.author.is_none() + bundle_id.developer.is_none() } pub(crate) async fn enable_built_in_extension( diff --git a/src-tauri/src/extension/built_in/quick_ai_access.rs b/src-tauri/src/extension/built_in/quick_ai_access.rs index b9f2421d..d0711185 100644 --- a/src-tauri/src/extension/built_in/quick_ai_access.rs +++ b/src-tauri/src/extension/built_in/quick_ai_access.rs @@ -3,7 +3,7 @@ pub(super) const EXTENSION_ID: &str = "QuickAIAccess"; pub(crate) const PLUGIN_JSON_FILE: &str = r#" { "id": "QuickAIAccess", - "title": "Quick AI Access", + "name": "Quick AI Access", "description": "...", "icon": "font_a-QuickAIAccess", "type": "ai_extension", diff --git a/src-tauri/src/extension/mod.rs b/src-tauri/src/extension/mod.rs index d1e69276..d4eb431d 100644 --- a/src-tauri/src/extension/mod.rs +++ b/src-tauri/src/extension/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod built_in; +pub(crate) mod store; mod third_party; use crate::common::document::OnOpened; @@ -18,6 +19,10 @@ pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local"; const PLUGIN_JSON_FILE_NAME: &str = "plugin.json"; const ASSETS_DIRECTORY_FILE_NAME: &str = "assets"; +fn default_true() -> bool { + true +} + #[derive(Debug, Deserialize, Serialize, Copy, Clone, Hash, PartialEq, Eq, Display)] #[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))] enum Platform { @@ -33,17 +38,17 @@ enum Platform { pub struct Extension { /// Extension ID. /// - /// The ID doesn't uniquely identifies an extension; Its bundle ID (ID & author) does. + /// The ID doesn't uniquely identifies an extension; Its bundle ID (ID & developer) does. id: String, /// Extension name. - title: String, - /// ID of the author. + name: String, + /// ID of the developer. /// /// * For built-in extensions, this will always be None. /// * For third-party first-layer extensions, the on-disk plugin.json file /// won't contain this field, but we will set this field for them after reading them into the memory. /// * For third-party sub extensions, this field will be None. - author: Option, + developer: Option, /// Platforms supported by this extension. /// /// If `None`, then this extension can be used on all the platforms. @@ -91,17 +96,23 @@ pub struct Extension { hotkey: Option, /// Is this extension enabled. + #[serde(default = "default_true")] enabled: bool, /// Extension settings #[serde(skip_serializing_if = "Option::is_none")] settings: Option, + + // We do not care about these fields, just take it regardless of what it is. + screenshots: Option, + url: Option, + version: Option, } /// Bundle ID uniquely identifies an extension. #[derive(Debug, Deserialize, Serialize, PartialEq, Clone)] pub(crate) struct ExtensionBundleId { - author: Option, + developer: Option, extension_id: String, sub_extension_id: Option, } @@ -111,7 +122,7 @@ impl Borrow for ExtensionBundleId { fn borrow(&self) -> Self::Target<'_> { ExtensionBundleIdBorrowed { - author: self.author.as_deref(), + developer: self.developer.as_deref(), extension_id: &self.extension_id, sub_extension_id: self.sub_extension_id.as_deref(), } @@ -121,7 +132,7 @@ impl Borrow for ExtensionBundleId { /// Reference version of `ExtensionBundleId`. #[derive(Debug, Serialize, PartialEq)] pub(crate) struct ExtensionBundleIdBorrowed<'ext> { - author: Option<&'ext str>, + developer: Option<&'ext str>, extension_id: &'ext str, sub_extension_id: Option<&'ext str>, } @@ -131,7 +142,7 @@ impl ToOwned for ExtensionBundleIdBorrowed<'_> { fn to_owned(&self) -> Self::Owned { ExtensionBundleId { - author: self.author.map(|s| s.to_string()), + developer: self.developer.map(|s| s.to_string()), extension_id: self.extension_id.to_string(), sub_extension_id: self.sub_extension_id.map(|s| s.to_string()), } @@ -140,7 +151,7 @@ impl ToOwned for ExtensionBundleIdBorrowed<'_> { impl<'ext> PartialEq> for ExtensionBundleId { fn eq(&self, other: &ExtensionBundleIdBorrowed<'ext>) -> bool { - self.author.as_deref() == other.author + self.developer.as_deref() == other.developer && self.extension_id == other.extension_id && self.sub_extension_id.as_deref() == other.sub_extension_id } @@ -148,7 +159,7 @@ impl<'ext> PartialEq> for ExtensionBundleId { impl<'ext> PartialEq for ExtensionBundleIdBorrowed<'ext> { fn eq(&self, other: &ExtensionBundleId) -> bool { - self.author == other.author.as_deref() + self.developer == other.developer.as_deref() && self.extension_id == other.extension_id && self.sub_extension_id == other.sub_extension_id.as_deref() } @@ -159,7 +170,7 @@ impl Extension { /// set to `None`, this may not be what you want. pub(crate) fn bundle_id_borrowed(&self) -> ExtensionBundleIdBorrowed<'_> { ExtensionBundleIdBorrowed { - author: self.author.as_deref(), + developer: self.developer.as_deref(), extension_id: &self.id, sub_extension_id: None, } @@ -205,18 +216,12 @@ impl Extension { } } if let Some(ref scripts) = self.scripts { - if let Some(sub_ext) = scripts - .iter() - .find(|script| script.id == sub_extension_id) - { + if let Some(sub_ext) = scripts.iter().find(|script| script.id == sub_extension_id) { return Some(sub_ext); } } if let Some(ref quick_links) = self.quicklinks { - if let Some(sub_ext) = quick_links - .iter() - .find(|link| link.id == sub_extension_id) - { + if let Some(sub_ext) = quick_links.iter().find(|link| link.id == sub_extension_id) { return Some(sub_ext); } } @@ -264,7 +269,7 @@ impl Extension { #[derive(Debug, Deserialize, Serialize, Clone)] pub(crate) struct CommandAction { pub(crate) exec: String, - pub(crate) args: Vec, + pub(crate) args: Option>, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -301,12 +306,151 @@ impl ExtensionType { } } +/// Helper function to filter out the extensions that do not satisfy the specifies conditions. +/// +/// used in `list_extensions()` +fn filter_out_extensions( + extensions: &mut Vec, + query: Option<&str>, + extension_type: Option, + list_enabled: bool, +) { + // apply `list_enabled` + if list_enabled { + extensions.retain(|ext| ext.enabled); + for extension in extensions.iter_mut() { + if extension.r#type.contains_sub_items() { + if let Some(ref mut commands) = extension.commands { + commands.retain(|cmd| cmd.enabled); + } + if let Some(ref mut scripts) = extension.scripts { + scripts.retain(|script| script.enabled); + } + if let Some(ref mut quicklinks) = extension.quicklinks { + quicklinks.retain(|link| link.enabled); + } + } + } + } + + // apply extension type filter to non-group/extension extensions + if let Some(extension_type) = extension_type { + assert!( + extension_type != ExtensionType::Group && extension_type != ExtensionType::Extension, + "filtering in folder extensions is pointless" + ); + + extensions.retain(|ext| { + let ty = ext.r#type; + ty == ExtensionType::Group || ty == ExtensionType::Extension || ty == extension_type + }); + + // Filter sub-extensions to only include the requested type + for extension in extensions.iter_mut() { + if extension.r#type.contains_sub_items() { + if let Some(ref mut commands) = extension.commands { + commands.retain(|cmd| cmd.r#type == extension_type); + } + if let Some(ref mut scripts) = extension.scripts { + scripts.retain(|script| script.r#type == extension_type); + } + if let Some(ref mut quicklinks) = extension.quicklinks { + quicklinks.retain(|link| link.r#type == extension_type); + } + } + } + + // Application is special, technically, it should never be filtered out by + // this condition. But if our users will be surprising if they choose a + // non-Application type and see it in the results. So we do this to remedy the + // issue + if let Some(idx) = extensions.iter().position(|ext| { + ext.developer.is_none() + && ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME + }) { + if extension_type != ExtensionType::Application { + extensions.remove(idx); + } + } + } + + // apply query filter + if let Some(query) = query { + let match_closure = |ext: &Extension| { + let lowercase_title = ext.name.to_lowercase(); + let lowercase_alias = ext.alias.as_ref().map(|alias| alias.to_lowercase()); + let lowercase_query = query.to_lowercase(); + + lowercase_title.contains(&lowercase_query) + || lowercase_alias.map_or(false, |alias| alias.contains(&lowercase_query)) + }; + + extensions.retain(|ext| { + if ext.r#type.contains_sub_items() { + // Keep all group/extension types + true + } else { + // Apply filter to non-group/extension types + match_closure(ext) + } + }); + + // Filter sub-extensions in groups and extensions + for extension in extensions.iter_mut() { + if extension.r#type.contains_sub_items() { + if let Some(ref mut commands) = extension.commands { + commands.retain(&match_closure); + } + if let Some(ref mut scripts) = extension.scripts { + scripts.retain(&match_closure); + } + if let Some(ref mut quicklinks) = extension.quicklinks { + quicklinks.retain(&match_closure); + } + } + } + } + + // Remove parent extensions (Group/Extension types) that have no sub-items after filtering + extensions.retain(|ext| { + if !ext.r#type.contains_sub_items() { + return true; + } + + // We don't do this filter to applications since it is always empty, load at runtime. + if ext.developer.is_none() + && ext.id == built_in::application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME + { + return true; + } + + let has_commands = ext + .commands + .as_ref() + .map_or(false, |commands| !commands.is_empty()); + let has_scripts = ext + .scripts + .as_ref() + .map_or(false, |scripts| !scripts.is_empty()); + let has_quicklinks = ext + .quicklinks + .as_ref() + .map_or(false, |quicklinks| !quicklinks.is_empty()); + + has_commands || has_scripts || has_quicklinks + }); +} + /// Return value: /// /// * boolean: indicates if we found any invalid extensions /// * Vec: loaded extensions #[tauri::command] -pub(crate) async fn list_extensions() -> Result<(bool, Vec), String> { +pub(crate) async fn list_extensions( + query: Option, + extension_type: Option, + list_enabled: bool, +) -> Result<(bool, Vec), String> { log::trace!("loading extensions"); let third_party_dir = third_party::THIRD_PARTY_EXTENSIONS_DIRECTORY.as_path(); @@ -321,12 +465,19 @@ pub(crate) async fn list_extensions() -> Result<(bool, Vec), String> let built_in_extensions = built_in::list_built_in_extensions().await?; let found_invalid_extension = third_party_found_invalid_extension; - let extensions = { + let mut extensions = { third_party_extensions.extend(built_in_extensions); third_party_extensions }; + filter_out_extensions( + &mut extensions, + query.as_deref(), + extension_type, + list_enabled, + ); + Ok((found_invalid_extension, extensions)) } @@ -343,6 +494,9 @@ pub(crate) async fn init_extensions(mut extensions: Vec) -> Result<() ) .await?; + // extension store + search_source_registry_tauri_state .register_source(store::ExtensionStore).await; + // Init the built-in enabled extensions for built_in_extension in extensions .extract_if(.., |ext| { @@ -451,7 +605,7 @@ pub(crate) async fn is_extension_enabled(bundle_id: ExtensionBundleId) -> Result third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").is_extension_enabled(&bundle_id_borrowed).await } -fn canonicalize_relative_icon_path( +pub(crate) fn canonicalize_relative_icon_path( extension_dir: &Path, extension: &mut Extension, ) -> Result<(), String> { @@ -570,8 +724,8 @@ fn alter_extension_json_file( let json_file_path = { let mut path = extension_directory.to_path_buf(); - if let Some(author) = bundle_id.author { - path.push(author); + if let Some(developer) = bundle_id.developer { + path.push(developer); } path.push(bundle_id.extension_id); path.push(PLUGIN_JSON_FILE_NAME); diff --git a/src-tauri/src/extension/store.rs b/src-tauri/src/extension/store.rs new file mode 100644 index 00000000..c56fa366 --- /dev/null +++ b/src-tauri/src/extension/store.rs @@ -0,0 +1,358 @@ +//! Extension store related stuff. + +use crate::extension::canonicalize_relative_icon_path; +use super::LOCAL_QUERY_SOURCE_TYPE; +use crate::common::document::DataSourceReference; +use crate::common::document::Document; +use crate::common::error::SearchError; +use crate::common::search::QueryResponse; +use crate::common::search::QuerySource; +use crate::common::search::SearchQuery; +use crate::common::traits::SearchSource; +use crate::extension::third_party::THIRD_PARTY_EXTENSIONS_DIRECTORY; +use crate::extension::Extension; +use crate::extension::PLUGIN_JSON_FILE_NAME; +use crate::extension::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE; +use async_trait::async_trait; +use reqwest::Client; +use reqwest::StatusCode; +use serde_json::Map as JsonObject; +use serde_json::Value as Json; +use std::sync::LazyLock; + +const DATA_SOURCE_ID: &str = "extension_store"; + +pub(crate) struct ExtensionStore; + +#[async_trait] +impl SearchSource for ExtensionStore { + fn get_type(&self) -> QuerySource { + QuerySource { + r#type: LOCAL_QUERY_SOURCE_TYPE.into(), + name: hostname::get() + .unwrap_or(DATA_SOURCE_ID.into()) + .to_string_lossy() + .into(), + id: DATA_SOURCE_ID.into(), + } + } + + async fn search(&self, query: SearchQuery) -> Result { + const SCORE: f64 = 2000.0; + + let Some(query_string) = query.query_strings.get("query") else { + return Ok(QueryResponse { + source: self.get_type(), + hits: Vec::new(), + total_hits: 0, + }); + }; + + let lowercase_query_string = query_string.to_lowercase(); + let expected_str = "extension store"; + + if expected_str.contains(&lowercase_query_string) { + let doc = Document { + id: DATA_SOURCE_ID.to_string(), + category: Some(DATA_SOURCE_ID.to_string()), + title: Some("Extension Store".to_string()), + icon: Some("font_Store".to_string()), + source: Some(DataSourceReference { + r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()), + name: Some(DATA_SOURCE_ID.into()), + id: Some(DATA_SOURCE_ID.into()), + icon: None, + }), + ..Default::default() + }; + + Ok(QueryResponse { + source: self.get_type(), + hits: vec![(doc, SCORE)], + total_hits: 1, + }) + } else { + Ok(QueryResponse { + source: self.get_type(), + hits: Vec::new(), + total_hits: 0, + }) + } + } +} + +// Cache the client since it caches connections internally. +static CLIENT: LazyLock = LazyLock::new(|| Client::new()); + +#[tauri::command] +pub(crate) async fn search_extension( + query_params: Option>, +) -> Result, String> { + let query_params: Vec<(&str, &str)> = match query_params { + Some(ref v) => { + let mut parsed = Vec::new(); + for parameter in v.iter() { + let (key, value) = parameter + .split_once('=') + .expect("query parameter should contain a '='"); + parsed.push((key, value)); + } + + parsed + } + None => Vec::new(), + }; + + let response = CLIENT + .get("http://infini.tpddns.cn:27200/store/extension/_search") + .query(&query_params) + .send() + .await + .map_err(|e| format!("Failed to send request: {:?}", e))?; + + // The response of a ES style search request + let mut response: JsonObject = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {:?}", e))?; + + let hits_json = response + .remove("hits") + .expect("the JSON response should contain field [hits]"); + let mut hits = match hits_json { + Json::Object(obj) => obj, + _ => panic!( + "field [hits] should be a JSON object, but it is not, value: [{}]", + hits_json + ), + }; + + let Some(hits_hits_json) = hits.remove("hits") else { + return Ok(Vec::new()); + }; + + let hits_hits = match hits_hits_json { + Json::Array(arr) => arr, + _ => panic!( + "field [hits.hits] should be an array, but it is not, value: [{}]", + hits_hits_json + ), + }; + + let mut extensions = Vec::with_capacity(hits_hits.len()); + for hit in hits_hits { + let mut hit_obj = match hit { + Json::Object(obj) => obj, + _ => panic!( + "each hit in [hits.hits] should be a JSON object, but it is not, value: [{}]", + hit + ), + }; + let source = hit_obj + .remove("_source") + .expect("each hit should contain field [_source]"); + + let mut source_obj = match source { + Json::Object(obj) => obj, + _ => panic!( + "field [_source] should be a JSON object, but it is not, value: [{}]", + source + ), + }; + + let developer_id = source_obj + .get("developer") + .and_then(|dev| dev.get("id")) + .and_then(|id| id.as_str()) + .expect("developer.id should exist") + .to_string(); + + let extension_id = source_obj + .get("id") + .and_then(|id| id.as_str()) + .expect("extension id should exist") + .to_string(); + + let installed = is_extension_installed(developer_id, extension_id).await; + source_obj.insert("installed".to_string(), Json::Bool(installed)); + + extensions.push(Json::Object(source_obj)); + } + + Ok(extensions) +} + +async fn is_extension_installed(developer: String, extension_id: String) -> bool { + THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE + .get() + .unwrap() + .extension_exists(&developer, &extension_id) + .await +} + +#[tauri::command] +pub(crate) async fn install_extension(id: String) -> Result<(), String> { + let response = CLIENT + .get(format!( + "http://infini.tpddns.cn:27200/store/extension/{}/_download", + id + )) + .send() + .await + .map_err(|e| format!("Failed to download extension: {}", e))?; + + if response.status() == StatusCode::NOT_FOUND { + return Err(format!("extension [{}] not found", id)); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read response bytes: {}", e))?; + + let cursor = std::io::Cursor::new(bytes); + let mut archive = + zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; + + let mut plugin_json = archive.by_name("plugin.json").map_err(|e| e.to_string())?; + let mut plugin_json_content = String::new(); + std::io::Read::read_to_string(&mut plugin_json, &mut plugin_json_content) + .map_err(|e| e.to_string())?; + let mut extension: Json = serde_json::from_str(&plugin_json_content) + .map_err(|e| format!("Failed to parse plugin.json: {}", e))?; + + let mut_ref_to_developer_object: &mut Json = extension + .as_object_mut() + .expect("plugin.json should be an object") + .get_mut("developer") + .expect("plugin.json should contain field [developer]"); + let developer_id = mut_ref_to_developer_object + .get("id") + .expect("plugin.json should contain [developer.id]") + .as_str() + .expect("plugin.json field [developer.id] should be a string"); + *mut_ref_to_developer_object = Json::String(developer_id.into()); + + // Set IDs for sub-extensions (commands, quicklinks, scripts) + let mut counter = 0; + // Set IDs for commands + // Helper function to set IDs for array fields + fn set_ids_for_field(extension: &mut Json, field_name: &str, counter: &mut i32) { + if let Some(field) = extension.as_object_mut().unwrap().get_mut(field_name) { + if let Some(array) = field.as_array_mut() { + for item in array { + if let Some(item_obj) = item.as_object_mut() { + if !item_obj.contains_key("id") { + item_obj.insert("id".to_string(), Json::String(counter.to_string())); + *counter += 1; + } + } + } + } + } + } + + // Set IDs for sub-extensions + set_ids_for_field(&mut extension, "commands", &mut counter); + set_ids_for_field(&mut extension, "quicklinks", &mut counter); + set_ids_for_field(&mut extension, "scripts", &mut counter); + + let mut extension: Extension = serde_json::from_value(extension).unwrap_or_else(|e| { + panic!( + "cannot parse plugin.json as struct Extension, error [{:?}]", + e + ); + }); + + drop(plugin_json); + + let developer = extension.developer.clone().unwrap_or_default(); + let extension_id = extension.id.clone(); + + // Extract the zip file + let extension_directory = { + let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.to_path_buf(); + path.push(developer); + path.push(extension_id.as_str()); + path + }; + + tokio::fs::create_dir_all(extension_directory.as_path()) + .await + .map_err(|e| e.to_string())?; + + // Extract all files except plugin.json + for i in 0..archive.len() { + let mut file = archive.by_index(i).map_err(|e| e.to_string())?; + let outpath = match file.enclosed_name() { + Some(path) => extension_directory.join(path), + None => continue, + }; + + // Skip the plugin.json file as we'll create it from the extension variable + if file.name() == "plugin.json" { + continue; + } + + if file.name().ends_with('/') { + tokio::fs::create_dir_all(&outpath).await.map_err(|e| e.to_string())?; + } else { + if let Some(p) = outpath.parent() { + if !p.exists() { + tokio::fs::create_dir_all(p).await.map_err(|e| e.to_string())?; + } + } + let mut outfile = tokio::fs::File::create(&outpath).await.map_err(|e| e.to_string())?; + let mut content = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut content).map_err(|e| e.to_string())?; + tokio::io::AsyncWriteExt::write_all(&mut outfile, &content).await.map_err(|e| e.to_string())?; + } + } + + // Create plugin.json from the extension variable + let plugin_json_path = extension_directory.join(PLUGIN_JSON_FILE_NAME); + let extension_json = serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?; + tokio::fs::write(&plugin_json_path, extension_json).await.map_err(|e| e.to_string())?; + + + // Turn it into an absolute path if it is a valid relative path because frontend code need this. + canonicalize_relative_icon_path(&extension_directory, &mut extension)?; + + THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE + .get() + .unwrap() + .add_extension(extension) + .await; + + Ok(()) +} + +#[tauri::command] +pub(crate) async fn uninstall_extension( + developer: String, + extension_id: String, +) -> Result<(), String> { + let extension_dir = { + let mut path = THIRD_PARTY_EXTENSIONS_DIRECTORY.join(developer.as_str()); + path.push(extension_id.as_str()); + + path + }; + if !extension_dir.try_exists().map_err(|e| e.to_string())? { + panic!( + "we are uninstalling extension [{}/{}], but there is no such extension files on disk", + developer, extension_id + ) + } + tokio::fs::remove_dir_all(extension_dir.as_path()) + .await + .map_err(|e| e.to_string())?; + + THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE + .get() + .unwrap() + .remove_extension(&developer, &extension_id) + .await; + + Ok(()) +} diff --git a/src-tauri/src/extension/third_party.rs b/src-tauri/src/extension/third_party.rs index c4e8ab42..5e9fbe25 100644 --- a/src-tauri/src/extension/third_party.rs +++ b/src-tauri/src/extension/third_party.rs @@ -64,44 +64,44 @@ pub(crate) async fn list_third_party_extensions( let mut extensions = Vec::new(); - 'author: loop { - let opt_author_dir = extensions_dir_iter + 'developer: loop { + let opt_developer_dir = extensions_dir_iter .next_entry() .await .map_err(|e| e.to_string())?; - let Some(author_dir) = opt_author_dir else { + let Some(developer_dir) = opt_developer_dir else { break; }; - let author_dir_file_type = author_dir.file_type().await.map_err(|e| e.to_string())?; - if !author_dir_file_type.is_dir() { + let developer_dir_file_type = developer_dir.file_type().await.map_err(|e| e.to_string())?; + if !developer_dir_file_type.is_dir() { found_invalid_extensions = true; log::warn!( "file [{}] under the third party extension directory should be a directory, but it is not", - author_dir.file_name().display() + developer_dir.file_name().display() ); // Skip this file - continue 'author; + continue 'developer; } - let Ok(author) = author_dir.file_name().into_string() else { + let Ok(developer) = developer_dir.file_name().into_string() else { found_invalid_extensions = true; log::warn!( - "author [{}] ID is not UTF-8 encoded", - author_dir.file_name().display() + "developer [{}] ID is not UTF-8 encoded", + developer_dir.file_name().display() ); // Skip this file - continue 'author; + continue 'developer; }; - let mut author_dir_iter = read_dir(&author_dir.path()) + let mut developer_dir_iter = read_dir(&developer_dir.path()) .await .map_err(|e| e.to_string())?; 'extension: loop { - let opt_extension_dir = author_dir_iter + let opt_extension_dir = developer_dir_iter .next_entry() .await .map_err(|e| e.to_string())?; @@ -172,8 +172,8 @@ pub(crate) async fn list_third_party_extensions( continue; } - // Set extension's author info manually. - extension.author = Some(author.clone()); + // Set extension's developer info manually. + extension.developer = Some(developer.clone()); extensions.push(extension); } @@ -250,25 +250,6 @@ fn validate_extension( /// Checks that can be performed against an extension or a sub item. fn validate_extension_or_sub_item(extension: &Extension) -> bool { - // Only - // - // 1. letters - // 2. underscore - // 3. numbers - // - // are allowed in the ID. - if !extension - .id - .chars() - .all(|c| c.is_ascii_alphabetic() || c == '_') - { - log::warn!( - "invalid extension [{}], [id] should contain only letters, numbers, or underscores", - extension.id - ); - return false; - } - // If field `action` is Some, then it should be a Command if extension.action.is_some() && extension.r#type != ExtensionType::Command { log::warn!( @@ -341,15 +322,6 @@ fn validate_extension_or_sub_item(extension: &Extension) -> bool { } } - // The author field should not be set - if extension.author.is_some() { - log::warn!( - "invalid extension [{}], unknown field [author]", - extension.id, - ); - return false; - } - true } @@ -421,7 +393,7 @@ impl ThirdPartyExtensionsSearchSource { bundle_id: &ExtensionBundleIdBorrowed<'_>, ) -> Option<&'lock mut Extension> { let index = extensions_write_lock.iter().position(|ext| { - ext.id == bundle_id.extension_id && ext.author.as_deref() == bundle_id.author + ext.id == bundle_id.extension_id && ext.developer.as_deref() == bundle_id.developer })?; let extension = extensions_write_lock @@ -659,7 +631,7 @@ impl ThirdPartyExtensionsSearchSource { Ok(()) } - /// Initialize the third-party extensions, which literally means + /// Initialize the third-party extensions, which literally means /// enabling/activating the enabled extensions. pub(super) async fn init(&self) -> Result<(), String> { let extensions_read_lock = self.inner.extensions.read().await; @@ -794,7 +766,7 @@ impl ThirdPartyExtensionsSearchSource { .iter() .find(|root_ext| { root_ext.id == bundle_id.extension_id - && root_ext.author.as_deref() == bundle_id.author + && root_ext.developer.as_deref() == bundle_id.developer }) .ok_or_else(|| { format!( @@ -825,6 +797,51 @@ impl ThirdPartyExtensionsSearchSource { // 2. It is enabled Ok(root_extension.enabled && sub_extension.enabled) } + + pub(crate) async fn extension_exists(&self, developer: &str, extension_id: &str) -> bool { + let read_lock_guard = self.inner.extensions.read().await; + read_lock_guard + .iter() + .any(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id) + } + + pub(crate) async fn add_extension(&self, extension: Extension) { + assert!( + extension.developer.is_some(), + "loaded third party extension should have its developer set" + ); + + let mut write_lock_guard = self.inner.extensions.write().await; + if write_lock_guard + .iter() + .any(|ext| ext.developer == extension.developer && ext.id == extension.id) + { + panic!( + "extension [{}/{}] already installed", + extension + .developer + .as_ref() + .expect("just checked it is Some"), + extension.id + ); + } + write_lock_guard.push(extension); + } + + pub(crate) async fn remove_extension(&self, developer: &str, extension_id: &str) { + let mut write_lock_guard = self.inner.extensions.write().await; + let Some(index) = write_lock_guard + .iter() + .position(|ext| ext.developer.as_deref() == Some(developer) && ext.id == extension_id) + else { + panic!( + "extension [{}/{}] not installed, but we are trying to remove it", + developer, extension_id + ); + }; + + write_lock_guard.remove(index); + } } pub(super) static THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE: OnceLock = @@ -955,7 +972,7 @@ fn extension_to_hit( // Score based on title match // Title is considered more important, so it gets a higher weight. if let Some(title_score) = - calculate_text_similarity(&query_lower, &extension.title.to_lowercase()) + calculate_text_similarity(&query_lower, &extension.name.to_lowercase()) { total_score += title_score * 1.0; // Weight for title } @@ -980,7 +997,7 @@ fn extension_to_hit( let document = Document { id: extension.id.clone(), - title: Some(extension.title.clone()), + title: Some(extension.name.clone()), icon: Some(extension.icon.clone()), on_opened: Some(on_opened), url: Some(url), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9bad7b64..b6413ab7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,6 +156,9 @@ pub fn run() { extension::register_extension_hotkey, extension::unregister_extension_hotkey, extension::is_extension_enabled, + extension::store::search_extension, + extension::store::install_extension, + extension::store::uninstall_extension, settings::set_allow_self_signature, settings::get_allow_self_signature, assistant::ask_ai, @@ -396,7 +399,8 @@ fn move_window_to_active_monitor(window: &WebviewWindow) { #[tauri::command] async fn get_app_search_source(app_handle: AppHandle) -> Result<(), String> { - let (_found_invalid_extensions, extensions) = extension::list_extensions() + // We want all the extensions here, so no filter condition specified. + let (_found_invalid_extensions, extensions) = extension::list_extensions(None, None, false) .await .map_err(|e| e.to_string())?; extension::init_extensions(extensions).await?; diff --git a/src/components/Common/DeleteDialog.tsx b/src/components/Common/DeleteDialog.tsx new file mode 100644 index 00000000..d42a0a1f --- /dev/null +++ b/src/components/Common/DeleteDialog.tsx @@ -0,0 +1,119 @@ +import { + Button, + ButtonProps, + Description, + Dialog, + DialogPanel, + DialogTitle, +} from "@headlessui/react"; +import { FC, KeyboardEvent } from "react"; +import clsx from "clsx"; +import { twMerge } from "tailwind-merge"; + +import VisibleKey from "./VisibleKey"; +import { useTranslation } from "react-i18next"; + +interface DeleteDialogProps { + isOpen: boolean; + title: string; + description: string; + deleteButtonProps?: ButtonProps; + cancelButtonProps?: ButtonProps; + reverseButtonPosition?: boolean; + setIsOpen: (isOpen: boolean) => void; + onCancel: () => void; + onDelete: () => void; +} + +const DeleteDialog: FC = (props) => { + const { + isOpen, + setIsOpen, + title, + description, + deleteButtonProps, + cancelButtonProps, + reverseButtonPosition, + onCancel, + onDelete, + } = props; + const { t } = useTranslation(); + + const handleEnter = (event: KeyboardEvent, fn: () => void) => { + if (event.code !== "Enter") return; + + event.stopPropagation(); + event.preventDefault(); + + fn(); + }; + + return ( + setIsOpen(false)} + className="relative z-1000" + > +
+ +
+ {title} + {description} +
+ +
+ + + + + + + +
+
+
+
+ ); +}; + +export default DeleteDialog; diff --git a/src/components/Common/SearchEmpty.tsx b/src/components/Common/SearchEmpty.tsx new file mode 100644 index 00000000..2678b9c6 --- /dev/null +++ b/src/components/Common/SearchEmpty.tsx @@ -0,0 +1,118 @@ +import { useThemeStore } from "@/stores/themeStore"; +import { FC } from "react"; +import { useTranslation } from "react-i18next"; + +interface SearchEmptyProps { + width?: number; + height?: number; +} + +const SearchEmpty: FC = (props) => { + const { width = 108, height } = props; + const { isDark } = useThemeStore(); + const { t } = useTranslation(); + + return ( +
+ + 编组 7 + + + + + + + + + + + + + + + + + + + + + {t("search.main.noResults")} +
+ ); +}; + +export default SearchEmpty; diff --git a/src/components/Common/UI/Footer.tsx b/src/components/Common/UI/Footer.tsx index aa286275..8f39c191 100644 --- a/src/components/Common/UI/Footer.tsx +++ b/src/components/Common/UI/Footer.tsx @@ -18,6 +18,7 @@ import source_default_img from "@/assets/images/source_default.png"; import source_default_dark_img from "@/assets/images/source_default_dark.png"; import { useThemeStore } from "@/stores/themeStore"; import platformAdapter from "@/utils/platformAdapter"; +import FontIcon from "../Icons/FontIcon"; interface FooterProps { setIsPinnedWeb?: (value: boolean) => void; @@ -26,7 +27,13 @@ interface FooterProps { export default function Footer({ setIsPinnedWeb }: FooterProps) { const { t } = useTranslation(); - const { sourceData, goAskAi } = useSearchStore(); + const { + sourceData, + goAskAi, + selectedExtension, + visibleExtensionStore, + visibleExtensionDetail, + } = useSearchStore(); const isDark = useThemeStore((state) => state.isDark); @@ -56,6 +63,63 @@ export default function Footer({ setIsPinnedWeb }: FooterProps) { return platformAdapter.emitEvent("open_settings", ""); }, []); + const renderLeft = () => { + if (sourceData?.source?.name) { + return ( + + ); + } + + if (visibleExtensionDetail && selectedExtension) { + return ( +
+ + {selectedExtension.name} +
+ ); + } + + if (visibleExtensionStore) { + return ( +
+ + Extension Store +
+ ); + } + + return ( + <> + {t("search.footer.logoAlt")} + +
+ {updateInfo?.available ? ( +
setVisible(true)}> + {t("search.footer.updateAvailable")} + +
+ ) : ( + sourceData?.source?.name || + t("search.footer.version", { + version: process.env.VERSION || "v1.0.0", + }) + )} +
+ + ); + }; + return (
- {sourceData?.source?.name ? ( - - ) : ( - {t("search.footer.logoAlt")} - )} -
- {updateInfo?.available ? ( -
setVisible(true)} - > - {t("search.footer.updateAvailable")} - -
- ) : ( - sourceData?.source?.name || - t("search.footer.version", { - version: process.env.VERSION || "v1.0.0", - }) - )} -
+ {renderLeft()} + )} +
+
+ + {(selectedExtension.screenshots?.length ?? 0) > 0 && ( +
+ {selectedExtension.screenshots.map((item) => { + return ( + + ); + })} +
+ )} + +
+ {t("extensionDetail.label.description")} +
+
+ {selectedExtension.description} +
+ + {(selectedExtension.commands?.length ?? 0) > 0 && ( + <> +
{t("extensionDetail.label.commands")}
+ + {selectedExtension.commands?.map((item) => { + return ( +
+
{item.name}
+
{item.description}
+
+ ); + })} + + )} + + {(selectedExtension.tags?.length ?? 0) > 0 && ( + <> +
{t("extensionDetail.label.tags")}
+
+ {selectedExtension.tags?.map((item) => { + return ( +
+ {item} +
+ ); + })} +
+ + )} + + {t("extensionDetail.label.lastUpdate")} +
+ {dayjs(selectedExtension.updated).format("YYYY-MM-DD HH:mm:ss")} +
+ + + + + ) + ); +}; + +export default ExtensionDetail; diff --git a/src/components/Search/ExtensionStore.tsx b/src/components/Search/ExtensionStore.tsx new file mode 100644 index 00000000..192cd1c4 --- /dev/null +++ b/src/components/Search/ExtensionStore.tsx @@ -0,0 +1,338 @@ +import { useSearchStore } from "@/stores/searchStore"; +import { parseSearchQuery } from "@/utils"; +import platformAdapter from "@/utils/platformAdapter"; +import { useAsyncEffect, useDebounce, useKeyPress, useUnmount } from "ahooks"; +import SearchEmpty from "../Common/SearchEmpty"; +import { useEffect, useState } from "react"; +import { CircleCheck, FolderDown, Loader } from "lucide-react"; +import clsx from "clsx"; +import ExtensionDetail from "./ExtensionDetail"; +import { useShortcutsStore } from "@/stores/shortcutsStore"; +import { useAppStore } from "@/stores/appStore"; +import { platform } from "@/utils/platform"; +import { useTranslation } from "react-i18next"; + +export interface SearchExtensionItem { + id: string; + created: string; + updated: string; + name: string; + description: string; + icon: string; + type: string; + category: string; + tags?: string[]; + platforms: string[]; + developer: { + id: string; + name: string; + avatar: string; + twitter_handle?: string; + github_handle?: string; + location?: string; + website?: string; + bio?: string; + }; + contributors: { + id: string; + name: string; + avatar: string; + }[]; + url: { + code: string; + download: string; + }; + version: { + number: string; + }; + screenshots: { + title?: string; + url: string; + }[]; + action: { + exec: string; + args: string[]; + }; + enabled: boolean; + stats: { + installs: number; + views: number; + }; + checksum: string; + installed: boolean; + commands?: Array<{ + type: string; + name: string; + icon: string; + description: string; + action: { + exec: string; + args: string[]; + }; + }>; +} + +const ExtensionStore = () => { + const { + searchValue, + selectedExtension, + setSelectedExtension, + installingExtensions, + setInstallingExtensions, + uninstallingExtensions, + setUninstallingExtensions, + visibleExtensionDetail, + setVisibleExtensionDetail, + visibleContextMenu, + setVisibleContextMenu, + } = useSearchStore(); + const debouncedSearchValue = useDebounce(searchValue); + const [list, setList] = useState([]); + const { modifierKey } = useShortcutsStore(); + const { addError } = useAppStore(); + const { t } = useTranslation(); + + useEffect(() => { + const unlisten1 = platformAdapter.listenEvent("install-extension", () => { + handleInstall(); + }); + + const unlisten2 = platformAdapter.listenEvent("uninstall-extension", () => { + handleUnInstall(); + }); + + return () => { + unlisten1.then((fn) => fn()); + unlisten2.then((fn) => fn()); + }; + }, [selectedExtension]); + + useAsyncEffect(async () => { + if (!debouncedSearchValue.trim()) { + return setList([]); + } + + const result = await platformAdapter.invokeBackend( + "search_extension", + { + queryParams: parseSearchQuery({ + query: debouncedSearchValue, + filters: { + platforms: [platform()], + }, + }), + } + ); + + console.log("search_extension", result); + + setList(result ?? []); + + setSelectedExtension(result?.[0]); + }, [debouncedSearchValue]); + + useUnmount(() => { + setSelectedExtension(void 0); + }); + + useKeyPress( + "enter", + () => { + if (visibleContextMenu) return; + + if (visibleExtensionDetail) { + return handleInstall(); + } + + setVisibleExtensionDetail(true); + }, + { exactMatch: true } + ); + + useKeyPress( + `${modifierKey}.enter`, + () => { + if ( + visibleContextMenu || + visibleExtensionDetail || + selectedExtension?.installed + ) { + return; + } + + handleInstall(); + }, + { exactMatch: true } + ); + + useKeyPress(["uparrow", "downarrow"], (_, key) => { + console.log("visibleContextMenu", visibleContextMenu); + + if (visibleContextMenu || visibleExtensionDetail) return; + + const index = list.findIndex((item) => item.id === selectedExtension?.id); + const length = list.length; + + if (length <= 1) return; + + let nextIndex = index; + + if (key === "uparrow") { + nextIndex = nextIndex > 0 ? nextIndex - 1 : length - 1; + } else { + nextIndex = nextIndex < length - 1 ? nextIndex + 1 : 0; + } + + setSelectedExtension(list[nextIndex]); + }); + + const toggleInstall = (installed = true) => { + if (!selectedExtension) return; + + const { id } = selectedExtension; + + setList((prev) => { + return prev.map((item) => { + if (item.id === id) { + return { ...item, installed }; + } + + return item; + }); + }); + + if (selectedExtension?.id === id) { + setSelectedExtension({ + ...selectedExtension, + installed, + }); + } + }; + + const handleInstall = async () => { + if (!selectedExtension) return; + + const { id, name, installed } = selectedExtension; + + try { + if (installed || installingExtensions.includes(id)) return; + + setInstallingExtensions(installingExtensions.concat(id)); + + await platformAdapter.invokeBackend("install_extension", { id }); + + toggleInstall(); + + addError( + `${name} ${t("extensionStore.hints.installationCompleted")}`, + "info" + ); + } catch (error) { + addError(String(error), "error"); + } finally { + setInstallingExtensions( + installingExtensions.filter((item) => item !== id) + ); + } + }; + + const handleUnInstall = async () => { + if (!selectedExtension) return; + + const { id, name, installed, developer } = selectedExtension; + + try { + if (!installed || uninstallingExtensions.includes(id)) return; + + setUninstallingExtensions(uninstallingExtensions.concat(id)); + + await platformAdapter.invokeBackend("uninstall_extension", { + developer: developer.id, + extensionId: id, + }); + + toggleInstall(false); + + addError( + `${name} ${t("extensionStore.hints.uninstallationCompleted")}`, + "info" + ); + } catch (error) { + addError(String(error), "error"); + } finally { + setUninstallingExtensions( + uninstallingExtensions.filter((item) => item !== id) + ); + } + }; + + return ( +
+ {visibleExtensionDetail ? ( + + ) : ( + <> + {list.length > 0 ? ( + list.map((item) => { + const { id, icon, name, description, stats, installed } = item; + + return ( +
{ + setSelectedExtension(item); + }} + onClick={() => { + setVisibleExtensionDetail(true); + }} + onContextMenu={(event) => { + event.preventDefault(); + + setVisibleContextMenu(true); + }} + > +
+ + {name} + {description} +
+ +
+ {installed && ( + + )} + + {installingExtensions.includes(item.id) && ( + + )} + +
+ + {stats.installs} +
+
+
+ ); + }) + ) : ( +
+ +
+ )} + + )} +
+ ); +}; + +export default ExtensionStore; diff --git a/src/components/Search/InputBox.tsx b/src/components/Search/InputBox.tsx index 1ac8c885..778f407e 100644 --- a/src/components/Search/InputBox.tsx +++ b/src/components/Search/InputBox.tsx @@ -97,6 +97,8 @@ export default function ChatInput({ const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null); const { curChatEnd, connected } = useChatStore(); + const { setSearchValue, visibleExtensionStore, selectedExtension } = + useSearchStore(); useEffect(() => { const handleFocus = () => { @@ -135,6 +137,7 @@ export default function ChatInput({ const handleInputChange = useCallback( (value: string) => { changeInput(value); + setSearchValue(value); if (!isChatMode) { onSend(value); } @@ -215,15 +218,16 @@ export default function ChatInput({ disabledChange={disabledChange} /> - {!isChatMode && sourceData && ( -
- -
- )} + {!isChatMode && + (sourceData || visibleExtensionStore || selectedExtension) && ( +
+ +
+ )} {/*
{t("search.askCocoAi.title", { diff --git a/src/components/Search/InputControls.tsx b/src/components/Search/InputControls.tsx index 9bc365a2..eb66d47a 100644 --- a/src/components/Search/InputControls.tsx +++ b/src/components/Search/InputControls.tsx @@ -190,6 +190,7 @@ const InputControls = ({ return state.aiOverviewAssistant; }); const aiOverviewShortcut = useShortcutsStore((state) => state.aiOverview); + const { visibleExtensionStore } = useSearchStore(); return (
{ if (selectedSearchContent?.type === "AI Assistant") { @@ -46,6 +48,7 @@ const SearchResultsPanel = memo<{ } }, [selectedSearchContent]); + if (visibleExtensionStore) return ; if (goAskAi) return ; if (suggests.length === 0) return ; diff --git a/src/components/Search/SearchIcons.tsx b/src/components/Search/SearchIcons.tsx index 7152e9d2..4f3ce1fa 100644 --- a/src/components/Search/SearchIcons.tsx +++ b/src/components/Search/SearchIcons.tsx @@ -14,7 +14,16 @@ export default function SearchIcons({ isChatMode, assistant, }: SearchIconsProps) { - const { sourceData, setSourceData, goAskAi, setGoAskAi } = useSearchStore(); + const { + sourceData, + setSourceData, + goAskAi, + setGoAskAi, + visibleExtensionStore, + setVisibleExtensionStore, + visibleExtensionDetail, + setVisibleExtensionDetail, + } = useSearchStore(); if (isChatMode) { return null; @@ -49,11 +58,21 @@ export default function SearchIcons({ ); } - if (sourceData) { + if (sourceData || visibleExtensionStore || visibleExtensionDetail) { return ( setSourceData(undefined)} + onClick={() => { + if (visibleExtensionDetail) { + return setVisibleExtensionDetail(false); + } + + if (visibleExtensionStore) { + return setVisibleExtensionStore(false); + } + + setSourceData(void 0); + }} /> ); } diff --git a/src/components/Settings/Extensions/components/Content/index.tsx b/src/components/Settings/Extensions/components/Content/index.tsx index 088bb55b..3a6ed2df 100644 --- a/src/components/Settings/Extensions/components/Content/index.tsx +++ b/src/components/Settings/Extensions/components/Content/index.tsx @@ -26,7 +26,7 @@ const Content = () => { interface ItemProps extends Extension { level: number; parentId?: ExtensionId; - parentAuthor?: string; + parentDeveloper?: string; parentDisabled?: boolean; } @@ -44,14 +44,14 @@ const Item: FC = (props) => { const { id, icon, - title, + name, type, level, platforms, - author, + developer, enabled, parentId, - parentAuthor, + parentDeveloper, parentDisabled, } = props; const { rootState } = useContext(ExtensionsContext); @@ -64,7 +64,7 @@ const Item: FC = (props) => { const [selfDisabled, setSelfDisabled] = useState(!enabled); const bundleId = { - author: author ?? parentAuthor, + developer: developer ?? parentDeveloper, extension_id: level === 1 ? id : parentId, sub_extension_id: level === 1 ? void 0 : id, }; @@ -100,7 +100,7 @@ const Item: FC = (props) => { state.loading = false; - return sortBy(subExtensions, ["title"]); + return sortBy(subExtensions, ["name"]); }; const handleExpand = async (event: MouseEvent) => { @@ -247,15 +247,15 @@ const Item: FC = (props) => { className={clsx("flex items-center justify-end", { "opacity-50 pointer-events-none": parentDisabled, })} - onClick={(event) => { - event.stopPropagation(); - }} > { + event.stopPropagation(); + }} />
); @@ -337,7 +337,7 @@ const Item: FC = (props) => { "opacity-50 pointer-events-none": isDisabled, })} > - {title} + {name}
@@ -358,7 +358,7 @@ const Item: FC = (props) => { {...item} level={level + 1} parentId={id} - parentAuthor={author} + parentDeveloper={developer} parentDisabled={!enabled} /> ); diff --git a/src/components/Settings/Extensions/components/Details/Application/index.tsx b/src/components/Settings/Extensions/components/Details/Application/index.tsx index 5045a7b8..c541584c 100644 --- a/src/components/Settings/Extensions/components/Details/Application/index.tsx +++ b/src/components/Settings/Extensions/components/Details/Application/index.tsx @@ -24,13 +24,13 @@ const App = () => { useAsyncEffect(async () => { if (!rootState.activeExtension) return; - const { id, title } = rootState.activeExtension; + const { id, name } = rootState.activeExtension; const appMetadata = await platformAdapter.invokeBackend( "get_app_metadata", { appPath: id, - appName: title, + appName: name, } ); diff --git a/src/components/Settings/Extensions/components/Details/index.tsx b/src/components/Settings/Extensions/components/Details/index.tsx index 1d05de4a..8f472456 100644 --- a/src/components/Settings/Extensions/components/Details/index.tsx +++ b/src/components/Settings/Extensions/components/Details/index.tsx @@ -26,7 +26,7 @@ const Details = () => { const renderContent = () => { if (!rootState.activeExtension) return; - const { id, type } = rootState.activeExtension; + const { id, type, description } = rootState.activeExtension; if (id === "Applications") { return ; @@ -56,12 +56,14 @@ const Details = () => { if (id === "Calculator") { return ; } + + return
{description}
; }; return (

- {rootState.activeExtension?.title} + {rootState.activeExtension?.name}

{renderContent()}
diff --git a/src/components/Settings/Extensions/index.tsx b/src/components/Settings/Extensions/index.tsx index 629cef8b..7c3c6d0a 100644 --- a/src/components/Settings/Extensions/index.tsx +++ b/src/components/Settings/Extensions/index.tsx @@ -1,5 +1,5 @@ import { createContext, useEffect } from "react"; -import { useMount, useReactive } from "ahooks"; +import { useReactive } from "ahooks"; import { useTranslation } from "react-i18next"; import type { LiteralUnion } from "type-fest"; import { cloneDeep, sortBy } from "lodash-es"; @@ -8,6 +8,10 @@ import platformAdapter from "@/utils/platformAdapter"; import Content from "./components/Content"; import Details from "./components/Details"; import { useExtensionsStore } from "@/stores/extensionsStore"; +import { Button } from "@headlessui/react"; +import { Plus } from "lucide-react"; +import SettingsInput from "../SettingsInput"; +import clsx from "clsx"; export type ExtensionId = LiteralUnion< "Applications" | "Calculator" | "QuickAIAccess" | "AIOverview", @@ -19,7 +23,7 @@ type ExtensionType = | "extension" | "application" | "script" - | "quick_link" + | "quicklink" | "setting" | "calculator" | "command" @@ -40,7 +44,7 @@ export interface Extension { id: ExtensionId; type: ExtensionType; icon: string; - title: string; + name: string; description: string; alias?: string; hotkey?: string; @@ -52,16 +56,26 @@ export interface Extension { scripts?: Extension[]; quicklinks?: Extension[]; settings: Record; - author?: string; + developer?: string; } +type Category = LiteralUnion< + "All" | "Commands" | "Scripts" | "Apps" | "QuickLinks", + string +>; + interface State { extensions: Extension[]; activeExtension?: Extension; + categories: Category[]; + currentCategory: Category; + searchValue?: string; } const INITIAL_STATE: State = { extensions: [], + categories: ["All", "Commands", "Scripts", "Apps", "QuickLinks"], + currentCategory: "All", }; export const ExtensionsContext = createContext<{ rootState: State }>({ @@ -71,16 +85,11 @@ export const ExtensionsContext = createContext<{ rootState: State }>({ export const Extensions = () => { const { t } = useTranslation(); const state = useReactive(cloneDeep(INITIAL_STATE)); + const { configId, setConfigId } = useExtensionsStore(); - useMount(async () => { - const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>( - "list_extensions" - ); - - const extensions = result[1]; - - state.extensions = sortBy(extensions, ["title"]); - }); + useEffect(() => { + getExtensions(); + }, [state.searchValue, state.currentCategory, configId]); useEffect(() => { const unsubscribe = useExtensionsStore.subscribe((state) => { @@ -92,19 +101,105 @@ export const Extensions = () => { }; }); + const getExtensions = async () => { + const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>( + "list_extensions", + { + query: state.searchValue, + extensionType: getExtensionType(), + listEnabled: false, + } + ); + + const extensions = result[1]; + + state.extensions = sortBy(extensions, ["name"]); + + if (configId) { + const matched = extensions.find((item) => item.id === configId); + + if (!matched) return; + + state.activeExtension = matched; + + setConfigId(void 0); + } + }; + + const getExtensionType = (): ExtensionType | undefined => { + switch (state.currentCategory) { + case "All": + return void 0; + case "Commands": + return "command"; + case "Scripts": + return "script"; + case "Apps": + return "application"; + case "QuickLinks": + return "quicklink"; + default: + return void 0; + } + }; + return ( -
+
-

- {t("settings.extensions.title")} -

+
+

+ {t("settings.extensions.title")} +

-
+ +
+ +
+
+ {state.categories.map((item) => { + return ( +
{ + state.currentCategory = item; + }} + > + {item} +
+ ); + })} +
+ + { + state.searchValue = String(value); + }} + /> +
+ + <>
{t("settings.extensions.list.name")}
@@ -125,7 +220,7 @@ export const Extensions = () => {
-
+
diff --git a/src/components/Settings/SettingsInput.tsx b/src/components/Settings/SettingsInput.tsx index 7719d27e..acc2ad8f 100644 --- a/src/components/Settings/SettingsInput.tsx +++ b/src/components/Settings/SettingsInput.tsx @@ -3,10 +3,10 @@ import clsx from "clsx"; import { isNumber } from "lodash-es"; import { FC, FocusEvent } from "react"; -import { specialCharacterFiltering } from "@/utils" +import { specialCharacterFiltering } from "@/utils"; interface SettingsInputProps extends Omit { - onChange: (value?: string | number) => void; + onChange?: (value?: string | number) => void; } const SettingsInput: FC = (props) => { @@ -40,7 +40,7 @@ const SettingsInput: FC = (props) => { )} onBlur={handleBlur} onChange={(event) => { - const value = specialCharacterFiltering(event.target.value) + const value = specialCharacterFiltering(event.target.value); onChange?.(value); }} /> diff --git a/src/hooks/useKeyboardHandlers.ts b/src/hooks/useKeyboardHandlers.ts index f764b54e..5ccf7e84 100644 --- a/src/hooks/useKeyboardHandlers.ts +++ b/src/hooks/useKeyboardHandlers.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect } from "react"; -import { isMetaOrCtrlKey } from "@/utils/keyboardUtils"; import { useSearchStore } from "@/stores/searchStore"; +import { useShortcutsStore } from "@/stores/shortcutsStore"; interface KeyboardHandlersProps { isChatMode: boolean; @@ -14,15 +14,38 @@ export function useKeyboardHandlers({ handleSubmit, curChatEnd, }: KeyboardHandlersProps) { - const { setSourceData } = useSearchStore(); + const { + setSourceData, + visibleExtensionStore, + setVisibleExtensionStore, + visibleExtensionDetail, + setVisibleExtensionDetail, + } = useSearchStore(); + const { modifierKey } = useShortcutsStore(); + + const getModifierKeyPressed = (event: KeyboardEvent) => { + const metaKeyPressed = event.metaKey && modifierKey === "meta"; + const ctrlKeyPressed = event.ctrlKey && modifierKey === "ctrl"; + const altKeyPressed = event.altKey && modifierKey === "alt"; + + return metaKeyPressed || ctrlKeyPressed || altKeyPressed; + }; const handleKeyDown = useCallback( (e: KeyboardEvent) => { // Handle ArrowLeft with meta key - if (e.code === "ArrowLeft" && isMetaOrCtrlKey(e)) { + if (e.code === "ArrowLeft" && getModifierKeyPressed(e)) { e.preventDefault(); - setSourceData(undefined); - return; + + if (visibleExtensionDetail) { + return setVisibleExtensionDetail(false); + } + + if (visibleExtensionStore) { + return setVisibleExtensionStore(false); + } + + return setSourceData(void 0); } // Handle Enter without meta key requirement @@ -31,7 +54,14 @@ export function useKeyboardHandlers({ curChatEnd && handleSubmit(); } }, - [isChatMode, handleSubmit, setSourceData, curChatEnd] + [ + isChatMode, + handleSubmit, + setSourceData, + curChatEnd, + modifierKey, + visibleExtensionDetail, + ] ); useEffect(() => { @@ -45,6 +75,7 @@ export function useKeyboardHandlers({ useEffect(() => { return () => { setSourceData(undefined); + setVisibleExtensionStore(false); }; }, []); } diff --git a/src/hooks/useScript.ts b/src/hooks/useScript.ts index 0fdebbf0..b962636c 100644 --- a/src/hooks/useScript.ts +++ b/src/hooks/useScript.ts @@ -30,5 +30,5 @@ export const useIconfontScript = () => { // Coco Server Icons useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js"); // Coco App Icons - useScript("https://at.alicdn.com/t/c/font_4934333_j1t3b1xyxkk.js"); + useScript("https://at.alicdn.com/t/c/font_4934333_80wr9yn2eup.js"); }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 62343b1c..bf325a6b 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -301,7 +301,10 @@ "updateAvailable": "Update available", "select": "Select", "open": "Open", - "powered": "Powered by Coco AI" + "powered": "Powered by Coco AI", + "install": "Install", + "details": "Details", + "uninstall": "Uninstall" }, "input": { "searchPlaceholder": "Search whatever you want ...", @@ -340,7 +343,11 @@ "title": { "calculator": "Calculator" }, - "search": "Search Operation" + "search": "Search Operation", + "details": "Details", + "install": "Install", + "uninstall": "Uninstall", + "configureExtension": "Configure Extension" }, "askCocoAi": { "title": "{{0}} {{1}}", @@ -506,5 +513,32 @@ "divide": "Divide", "remainder": "Remainder", "expression": "Expression" + }, + "extensionStore": { + "hints": { + "installationCompleted": "installation completed", + "uninstallationCompleted": "uninstallation completed" + } + }, + "extensionDetail": { + "label": { + "description": "Description", + "commands": "Commands", + "tags": "Tags", + "lastUpdate": "Last Update" + }, + "hints": { + "installed": "Installed" + }, + "deleteDialog": { + "title": "Uninstall", + "description": "This will remove all the data and commands associated with this extension." + } + }, + "deleteDialog": { + "button": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index ee25b643..3c06de45 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -301,7 +301,10 @@ "updateAvailable": "有可用更新", "select": "选择", "open": "打开", - "powered": "由 Coco AI 提供支持" + "powered": "由 Coco AI 提供支持", + "install": "安装", + "details": "详情", + "uninstall": "卸载" }, "input": { "searchPlaceholder": "搜索任何内容...", @@ -340,7 +343,11 @@ "title": { "calculator": "计算器" }, - "search": "搜索操作" + "search": "搜索操作", + "details": "详情", + "install": "安装", + "uninstall": "卸载", + "configureExtension": "配置扩展" }, "askCocoAi": { "title": "{{0}}{{1}}", @@ -505,5 +512,32 @@ "divide": "相除", "remainder": "求余", "expression": "表达式" + }, + "extensionStore": { + "hints": { + "installationCompleted": "安装成功", + "uninstallationCompleted": "卸载成功" + } + }, + "extensionDetail": { + "label": { + "description": "描述", + "commands": "命令", + "tags": "标签", + "lastUpdate": "最后更新时间" + }, + "hints": { + "installed": "已安装" + }, + "deleteDialog": { + "title": "卸载", + "description": "这将删除与该扩展相关的所有数据和命令。" + } + }, + "deleteDialog": { + "button": { + "cancel": "取消", + "delete": "删除" + } } } \ No newline at end of file diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx index d3f655ce..f34d0e10 100644 --- a/src/pages/settings/index.tsx +++ b/src/pages/settings/index.tsx @@ -15,6 +15,7 @@ import Extensions from "@/components/Settings/Extensions"; import { useConnectStore } from "@/stores/connectStore"; import platformAdapter from "@/utils/platformAdapter"; import { useAppStore } from "@/stores/appStore"; +import { useExtensionsStore } from "@/stores/extensionsStore"; const tabIndexMap: { [key: string]: number } = { general: 0, @@ -26,6 +27,7 @@ const tabIndexMap: { [key: string]: number } = { function SettingsPage() { const { t } = useTranslation(); + const { setConfigId } = useExtensionsStore(); useTray(); @@ -37,7 +39,7 @@ function SettingsPage() { { name: t("settings.tabs.about"), icon: Info }, ]; - const [defaultIndex, setDefaultIndex] = useState(0); + const [defaultIndex, setDefaultIndex] = useState(1); useEffect(() => { const unlisten = listen("tab_index", (event) => { @@ -56,10 +58,20 @@ function SettingsPage() { platformAdapter.emitEvent("change-app-store", state); }); + const unlisten2 = platformAdapter.listenEvent( + "config-extension", + ({ payload }) => { + platformAdapter.showWindow(); + setDefaultIndex(1); + setConfigId(payload); + } + ); + return () => { - unlisten.then((fn) => fn()); unsubscribeConnect(); unsubscribeAppStore(); + unlisten.then((fn) => fn()); + unlisten2.then((fn) => fn()); }; }, []); diff --git a/src/routes/layout.tsx b/src/routes/layout.tsx index 81c4195a..5a02e595 100644 --- a/src/routes/layout.tsx +++ b/src/routes/layout.tsx @@ -117,7 +117,10 @@ export default function Layout() { useMount(async () => { const result = await platformAdapter.invokeBackend<[boolean, Extension[]]>( - "list_extensions" + "list_extensions", + { + listEnabled: false, + } ); if (!isArray(result)) return; diff --git a/src/stores/extensionsStore.ts b/src/stores/extensionsStore.ts index 4b10c9ee..71683b25 100644 --- a/src/stores/extensionsStore.ts +++ b/src/stores/extensionsStore.ts @@ -19,6 +19,8 @@ export type IExtensionsStore = { setAiOverviewDelay: (aiOverviewDelay: number) => void; aiOverviewMinQuantity: number; setAiOverviewMinQuantity: (aiOverviewMinQuantity: number) => void; + configId?: string; + setConfigId: (configId?: string) => void; }; export const useExtensionsStore = create()( @@ -53,6 +55,9 @@ export const useExtensionsStore = create()( setAiOverviewMinQuantity(aiOverviewMinQuantity) { return set({ aiOverviewMinQuantity }); }, + setConfigId(configId) { + return set({ configId }); + }, }), { name: "extensions-store", diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts index 2a73387c..4a41ef8a 100644 --- a/src/stores/searchStore.ts +++ b/src/stores/searchStore.ts @@ -1,3 +1,4 @@ +import { SearchExtensionItem } from "@/components/Search/ExtensionStore"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -28,6 +29,18 @@ export type ISearchStore = { setEnabledAiOverview: (enabledAiOverview: boolean) => void; askAiAssistantId?: string; setAskAiAssistantId: (askAiAssistantId?: string) => void; + visibleExtensionStore: boolean; + setVisibleExtensionStore: (visibleExtensionStore: boolean) => void; + searchValue: string; + setSearchValue: (searchValue: string) => void; + selectedExtension?: SearchExtensionItem; + setSelectedExtension: (selectedExtension?: SearchExtensionItem) => void; + installingExtensions: string[]; + setInstallingExtensions: (installingExtensions: string[]) => void; + uninstallingExtensions: string[]; + setUninstallingExtensions: (uninstallingExtensions: string[]) => void; + visibleExtensionDetail: boolean; + setVisibleExtensionDetail: (visibleExtensionDetail: boolean) => void; }; export const useSearchStore = create()( @@ -70,6 +83,29 @@ export const useSearchStore = create()( setAskAiAssistantId: (askAiAssistantId) => { return set({ askAiAssistantId }); }, + visibleExtensionStore: false, + setVisibleExtensionStore: (visibleExtensionStore) => { + return set({ visibleExtensionStore }); + }, + searchValue: "", + setSearchValue: (searchValue) => { + return set({ searchValue }); + }, + setSelectedExtension(selectedExtension) { + return set({ selectedExtension }); + }, + installingExtensions: [], + setInstallingExtensions: (installingExtensions) => { + return set({ installingExtensions }); + }, + uninstallingExtensions: [], + setUninstallingExtensions: (uninstallingExtensions) => { + return set({ uninstallingExtensions }); + }, + visibleExtensionDetail: false, + setVisibleExtensionDetail: (visibleExtensionDetail) => { + return set({ visibleExtensionDetail }); + }, }), { name: "search-store", diff --git a/src/types/platform.ts b/src/types/platform.ts index 52096e26..db371e96 100644 --- a/src/types/platform.ts +++ b/src/types/platform.ts @@ -44,6 +44,10 @@ export interface EventPayloads { "quick-ai-access-client-id": any; "ai-overview-client-id": any; "change-app-store": IAppStore; + "open-extension-store": void; + "install-extension": void; + "uninstall-extension": void; + "config-extension": string; } // Window operation interface diff --git a/src/utils/tauriAdapter.ts b/src/utils/tauriAdapter.ts index 0b1b84b5..871dfee1 100644 --- a/src/utils/tauriAdapter.ts +++ b/src/utils/tauriAdapter.ts @@ -235,7 +235,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => { async openSearchItem(data) { const { invoke } = await import("@tauri-apps/api/core"); - if (data.type === "AI Assistant") { + console.log("data", data); + + if (data?.type === "AI Assistant" || data?.id === "extension_store") { const textarea = document.querySelector("#search-textarea"); if (!(textarea instanceof HTMLTextAreaElement)) return;