Skip to content

Commit 32d4f45

Browse files
SteveLauCayangweb
andauthored
feat: support installing local extensions (#749)
This commit adds support for installing extensions from a local folder path: ```text extension-directory/ β”œβ”€β”€ assets/ β”‚ β”œβ”€β”€ icon.png β”‚ └── other-assets... └── plugin.json ``` Useful for testing and development of extensions before publishing. Co-authored-by: ayang <473033518@qq.com>
1 parent 6bc78b4 commit 32d4f45

File tree

10 files changed

+330
-28
lines changed

10 files changed

+330
-28
lines changed

β€Ždocs/content.en/docs/release-notes/_index.mdβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Information about release notes of Coco App is provided here.
1414
### πŸš€ Features
1515

1616
- feat: enhance ui for skipped version #834
17+
- feat: support installing local extensions #749
1718

1819
### πŸ› Bug fix
1920

β€Žsrc-tauri/src/extension/mod.rsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ pub(crate) async fn init_extensions(
498498

499499
// extension store
500500
search_source_registry_tauri_state
501-
.register_source(third_party::store::ExtensionStore)
501+
.register_source(third_party::install::store::ExtensionStore)
502502
.await;
503503

504504
// Init the built-in enabled extensions
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
use crate::extension::PLUGIN_JSON_FILE_NAME;
2+
use crate::extension::third_party::install::is_extension_installed;
3+
use crate::extension::third_party::{
4+
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE, get_third_party_extension_directory,
5+
};
6+
use crate::extension::{Extension, canonicalize_relative_icon_path};
7+
use serde_json::Value as Json;
8+
use std::path::Path;
9+
use std::path::PathBuf;
10+
use tauri::{AppHandle, Runtime};
11+
use tokio::fs;
12+
13+
/// All the extensions installed from local file will belong to a special developer
14+
/// "__local__".
15+
const DEVELOPER_ID_LOCAL: &str = "__local__";
16+
17+
/// Install the extension specified by `path`.
18+
///
19+
/// `path` should point to a directory with the following structure:
20+
///
21+
/// ```text
22+
/// extension-directory/
23+
/// β”œβ”€β”€ assets/
24+
/// β”‚ β”œβ”€β”€ icon.png
25+
/// β”‚ └── other-assets...
26+
/// └── plugin.json
27+
/// ```
28+
#[tauri::command]
29+
pub(crate) async fn install_local_extension<R: Runtime>(
30+
tauri_app_handle: AppHandle<R>,
31+
path: PathBuf,
32+
) -> Result<(), String> {
33+
let extension_dir_name = path
34+
.file_name()
35+
.ok_or_else(|| "Invalid extension: no directory name".to_string())?
36+
.to_str()
37+
.ok_or_else(|| "Invalid extension: non-UTF8 extension id".to_string())?;
38+
39+
// we use extension directory name as the extension ID.
40+
let extension_id = extension_dir_name;
41+
if is_extension_installed(DEVELOPER_ID_LOCAL, extension_id).await {
42+
// The frontend code uses this string to distinguish between 2 error cases:
43+
//
44+
// 1. This extension is already imported
45+
// 2. The selected directory does not contain a valid extension
46+
//
47+
// do NOT edit this without updating the frontend code.
48+
//
49+
// ```ts
50+
// if (errorMessage === "already imported") {
51+
// addError(t("extensionAlreadyImported"));
52+
// } else {
53+
// addError(t("settings.extensions.hints.importFailed"));
54+
// }
55+
// ```
56+
//
57+
// This is definitely error-prone, but we have to do this until we have
58+
// structured error type
59+
return Err("already imported".into());
60+
}
61+
62+
let plugin_json_path = path.join(PLUGIN_JSON_FILE_NAME);
63+
64+
let plugin_json_content = fs::read_to_string(&plugin_json_path)
65+
.await
66+
.map_err(|e| e.to_string())?;
67+
68+
// Parse as JSON first as it is not valid for `struct Extension`, we need to
69+
// correct it (set fields `id` and `developer`) before converting it to `struct Extension`:
70+
let mut extension_json: Json =
71+
serde_json::from_str(&plugin_json_content).map_err(|e| e.to_string())?;
72+
73+
// Set the main extension ID to the directory name
74+
let extension_obj = extension_json
75+
.as_object_mut()
76+
.expect("extension_json should be an object");
77+
extension_obj.insert("id".to_string(), Json::String(extension_id.to_string()));
78+
extension_obj.insert(
79+
"developer".to_string(),
80+
Json::String(DEVELOPER_ID_LOCAL.to_string()),
81+
);
82+
83+
// Counter for sub-extension IDs
84+
let mut counter = 1u32;
85+
86+
// Set IDs for commands
87+
if let Some(commands) = extension_obj.get_mut("commands") {
88+
if let Some(commands_array) = commands.as_array_mut() {
89+
for command in commands_array {
90+
if let Some(command_obj) = command.as_object_mut() {
91+
command_obj.insert("id".to_string(), Json::String(counter.to_string()));
92+
counter += 1;
93+
}
94+
}
95+
}
96+
}
97+
98+
// Set IDs for quicklinks
99+
if let Some(quicklinks) = extension_obj.get_mut("quicklinks") {
100+
if let Some(quicklinks_array) = quicklinks.as_array_mut() {
101+
for quicklink in quicklinks_array {
102+
if let Some(quicklink_obj) = quicklink.as_object_mut() {
103+
quicklink_obj.insert("id".to_string(), Json::String(counter.to_string()));
104+
counter += 1;
105+
}
106+
}
107+
}
108+
}
109+
110+
// Set IDs for scripts
111+
if let Some(scripts) = extension_obj.get_mut("scripts") {
112+
if let Some(scripts_array) = scripts.as_array_mut() {
113+
for script in scripts_array {
114+
if let Some(script_obj) = script.as_object_mut() {
115+
script_obj.insert("id".to_string(), Json::String(counter.to_string()));
116+
counter += 1;
117+
}
118+
}
119+
}
120+
}
121+
122+
// Now we can convert JSON to `struct Extension`
123+
let mut extension: Extension =
124+
serde_json::from_value(extension_json).map_err(|e| e.to_string())?;
125+
126+
// Create destination directory
127+
let dest_dir = get_third_party_extension_directory(&tauri_app_handle)
128+
.join(DEVELOPER_ID_LOCAL)
129+
.join(extension_dir_name);
130+
131+
fs::create_dir_all(&dest_dir)
132+
.await
133+
.map_err(|e| e.to_string())?;
134+
135+
// Copy all files except plugin.json
136+
let mut entries = fs::read_dir(&path).await.map_err(|e| e.to_string())?;
137+
138+
while let Some(entry) = entries.next_entry().await.map_err(|e| e.to_string())? {
139+
let file_name = entry.file_name();
140+
let file_name_str = file_name
141+
.to_str()
142+
.ok_or_else(|| "Invalid filename: non-UTF8".to_string())?;
143+
144+
// plugin.json will be handled separately.
145+
if file_name_str == PLUGIN_JSON_FILE_NAME {
146+
continue;
147+
}
148+
149+
let src_path = entry.path();
150+
let dest_path = dest_dir.join(&file_name);
151+
152+
if src_path.is_dir() {
153+
// Recursively copy directory
154+
copy_dir_recursively(&src_path, &dest_path).await?;
155+
} else {
156+
// Copy file
157+
fs::copy(&src_path, &dest_path)
158+
.await
159+
.map_err(|e| e.to_string())?;
160+
}
161+
}
162+
163+
// Write the corrected plugin.json file
164+
let corrected_plugin_json =
165+
serde_json::to_string_pretty(&extension).map_err(|e| e.to_string())?;
166+
167+
let dest_plugin_json_path = dest_dir.join(PLUGIN_JSON_FILE_NAME);
168+
fs::write(&dest_plugin_json_path, corrected_plugin_json)
169+
.await
170+
.map_err(|e| e.to_string())?;
171+
172+
// Canonicalize relative icon paths
173+
canonicalize_relative_icon_path(&dest_dir, &mut extension)?;
174+
175+
// Add extension to the search source
176+
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
177+
.get()
178+
.unwrap()
179+
.add_extension(extension)
180+
.await;
181+
182+
Ok(())
183+
}
184+
185+
/// Helper function to recursively copy directories.
186+
#[async_recursion::async_recursion]
187+
async fn copy_dir_recursively(src: &Path, dest: &Path) -> Result<(), String> {
188+
tokio::fs::create_dir_all(dest)
189+
.await
190+
.map_err(|e| e.to_string())?;
191+
let mut read_dir = tokio::fs::read_dir(src).await.map_err(|e| e.to_string())?;
192+
193+
while let Some(entry) = read_dir.next_entry().await.map_err(|e| e.to_string())? {
194+
let src_path = entry.path();
195+
let dest_path = dest.join(entry.file_name());
196+
197+
if src_path.is_dir() {
198+
copy_dir_recursively(&src_path, &dest_path).await?;
199+
} else {
200+
tokio::fs::copy(&src_path, &dest_path)
201+
.await
202+
.map_err(|e| e.to_string())?;
203+
}
204+
}
205+
206+
Ok(())
207+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//! This module contains the code of extension installation.
2+
//!
3+
//!
4+
//! # How
5+
//!
6+
//! Technically, installing an extension involves the following steps:
7+
//!
8+
//! 1. Correct the `plugin.json` JSON if it does not conform to our `struct Extension`
9+
//! definition.
10+
//!
11+
//! 2. Write the extension files to the corresponding location
12+
//!
13+
//! * developer directory
14+
//! * extension directory
15+
//! * assets directory
16+
//! * various assets files, e.g., "icon.png"
17+
//! * plugin.json file
18+
//!
19+
//! 3. Canonicalize the `Extension.icon` fields if they are relative paths
20+
//! (relative to the `assets` directory)
21+
//!
22+
//! 4. Deserialize the `plugin.json` file to a `struct Extension`, and call
23+
//! `THIRD_PARTY_EXTENSIONS_DIRECTORY.add_extension(extension)` to add it to
24+
//! the in-memory extension list.
25+
26+
pub(crate) mod local_extension;
27+
pub(crate) mod store;
28+
29+
use super::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE;
30+
31+
pub(crate) async fn is_extension_installed(developer: &str, extension_id: &str) -> bool {
32+
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
33+
.get()
34+
.unwrap()
35+
.extension_exists(developer, extension_id)
36+
.await
37+
}

β€Žsrc-tauri/src/extension/third_party/store.rsβ€Ž renamed to β€Žsrc-tauri/src/extension/third_party/install/store.rsβ€Ž

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Extension store related stuff.
22
3-
use super::LOCAL_QUERY_SOURCE_TYPE;
3+
use super::super::LOCAL_QUERY_SOURCE_TYPE;
4+
use super::is_extension_installed;
45
use crate::common::document::DataSourceReference;
56
use crate::common::document::Document;
67
use crate::common::error::SearchError;
@@ -152,14 +153,12 @@ pub(crate) async fn search_extension(
152153
.get("developer")
153154
.and_then(|dev| dev.get("id"))
154155
.and_then(|id| id.as_str())
155-
.expect("developer.id should exist")
156-
.to_string();
156+
.expect("developer.id should exist");
157157

158158
let extension_id = source_obj
159159
.get("id")
160160
.and_then(|id| id.as_str())
161-
.expect("extension id should exist")
162-
.to_string();
161+
.expect("extension id should exist");
163162

164163
let installed = is_extension_installed(developer_id, extension_id).await;
165164
source_obj.insert("installed".to_string(), Json::Bool(installed));
@@ -170,14 +169,6 @@ pub(crate) async fn search_extension(
170169
Ok(extensions)
171170
}
172171

173-
async fn is_extension_installed(developer: String, extension_id: String) -> bool {
174-
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
175-
.get()
176-
.unwrap()
177-
.extension_exists(&developer, &extension_id)
178-
.await
179-
}
180-
181172
#[tauri::command]
182173
pub(crate) async fn install_extension_from_store(
183174
tauri_app_handle: AppHandle,

β€Žsrc-tauri/src/extension/third_party/mod.rsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
pub(crate) mod store;
1+
pub(crate) mod install;
22

33
use super::Extension;
44
use super::ExtensionType;

β€Žsrc-tauri/src/lib.rsβ€Ž

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,9 @@ pub fn run() {
166166
extension::register_extension_hotkey,
167167
extension::unregister_extension_hotkey,
168168
extension::is_extension_enabled,
169-
extension::third_party::store::search_extension,
170-
extension::third_party::store::install_extension_from_store,
169+
extension::third_party::install::store::search_extension,
170+
extension::third_party::install::store::install_extension_from_store,
171+
extension::third_party::install::local_extension::install_local_extension,
171172
extension::third_party::uninstall_extension,
172173
settings::set_allow_self_signature,
173174
settings::get_allow_self_signature,

0 commit comments

Comments
Β (0)