mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-17 16:37:20 +02:00
feat: extension export via api
This commit is contained in:
+743
-33
@@ -7,7 +7,7 @@ use crate::tag_manager::TAG_MANAGER;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
||||
Path, Query, State,
|
||||
DefaultBodyLimit, Path, Query, State,
|
||||
},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
middleware::{self, Next},
|
||||
@@ -41,6 +41,10 @@ pub struct ApiProfile {
|
||||
pub is_running: bool,
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
pub vpn_id: Option<String>,
|
||||
/// Extension group loaded into the browser at launch. Settable via
|
||||
/// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it
|
||||
/// set instead of having to go through the desktop app.
|
||||
pub extension_group_id: Option<String>,
|
||||
pub clear_on_close: bool,
|
||||
/// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
|
||||
/// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read
|
||||
@@ -84,6 +88,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
|
||||
is_running: profile.process_id.is_some(),
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
vpn_id: profile.vpn_id.clone(),
|
||||
extension_group_id: profile.extension_group_id.clone(),
|
||||
clear_on_close: profile.clear_on_close,
|
||||
sync_mode: format!("{:?}", profile.sync_mode),
|
||||
cloud_sync_enabled: profile.is_sync_enabled(),
|
||||
@@ -416,6 +421,59 @@ struct ImportCookiesResponse {
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Add an extension from exactly one source: an uploaded payload
|
||||
/// (`file_name` together with `file_data_base64`), or `source_path` on the
|
||||
/// machine running Donut. Supplying both, or neither, is a 400.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateExtensionRequest {
|
||||
/// Display name. Optional — the manifest's own name wins when it has one,
|
||||
/// and a blank name is only rejected when the manifest has none either.
|
||||
pub name: Option<String>,
|
||||
/// Name of the uploaded file. Its suffix picks the type: `.crx` or `.zip`.
|
||||
pub file_name: Option<String>,
|
||||
/// Payload bytes, standard base64. Only meaningful with `file_name`.
|
||||
pub file_data_base64: Option<String>,
|
||||
/// Path on this machine: a `.crx`/`.zip`, or an unpacked extension
|
||||
/// directory holding a top-level `manifest.json`.
|
||||
pub source_path: Option<String>,
|
||||
/// Load a `source_path` directory in place instead of copying it into the
|
||||
/// store, so edits to the folder apply on the next browser start. Directory
|
||||
/// sources only, and a linked extension never syncs.
|
||||
pub link: Option<bool>,
|
||||
}
|
||||
|
||||
/// Replace an extension's payload, rename it, or both. Every field is
|
||||
/// optional, but a request that carries neither a name nor a source has
|
||||
/// nothing to do and is a 400.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateExtensionRequest {
|
||||
/// New display name.
|
||||
pub name: Option<String>,
|
||||
/// Name of the replacement upload. Its suffix picks the type: `.crx` or `.zip`.
|
||||
pub file_name: Option<String>,
|
||||
/// Replacement payload bytes, standard base64. Only meaningful with `file_name`.
|
||||
pub file_data_base64: Option<String>,
|
||||
/// Path on this machine to re-import from: a `.crx`/`.zip`, or an unpacked
|
||||
/// extension directory.
|
||||
pub source_path: Option<String>,
|
||||
/// Load a `source_path` directory in place instead of copying it in.
|
||||
pub link: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateExtensionGroupRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateExtensionGroupRequest {
|
||||
/// New group name.
|
||||
pub name: Option<String>,
|
||||
/// Replaces the whole membership list. Omit to leave it untouched; use the
|
||||
/// `/extensions/{extension_id}` sub-routes to add or remove one member.
|
||||
pub extension_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct BatchRunRequest {
|
||||
/// Profile IDs to launch.
|
||||
@@ -561,9 +619,17 @@ struct ImportProxiesResponse {
|
||||
update_vpn,
|
||||
delete_vpn,
|
||||
get_extensions,
|
||||
get_extension_groups,
|
||||
create_extension_api,
|
||||
get_extension_api,
|
||||
update_extension_api,
|
||||
delete_extension_api,
|
||||
get_extension_groups,
|
||||
create_extension_group_api,
|
||||
get_extension_group_api,
|
||||
update_extension_group_api,
|
||||
delete_extension_group_api,
|
||||
add_extension_to_group_api,
|
||||
remove_extension_from_group_api,
|
||||
download_browser_api,
|
||||
get_browser_versions,
|
||||
check_browser_downloaded,
|
||||
@@ -624,6 +690,12 @@ struct ImportProxiesResponse {
|
||||
OpenUrlRequest,
|
||||
ImportCookiesRequest,
|
||||
ImportCookiesResponse,
|
||||
CreateExtensionRequest,
|
||||
UpdateExtensionRequest,
|
||||
CreateExtensionGroupRequest,
|
||||
UpdateExtensionGroupRequest,
|
||||
crate::extension_manager::Extension,
|
||||
crate::extension_manager::ExtensionGroup,
|
||||
ProxySettings,
|
||||
DetectedProfilesResponse,
|
||||
ImportProfilesRequest,
|
||||
@@ -838,15 +910,37 @@ fn build_v1_router() -> Router<ApiServerState> {
|
||||
.routes(routes!(import_vpn))
|
||||
.routes(routes!(export_vpn))
|
||||
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
||||
.routes(routes!(get_extensions))
|
||||
.routes(routes!(delete_extension_api))
|
||||
.routes(routes!(get_extension_groups))
|
||||
.routes(routes!(delete_extension_group_api))
|
||||
.routes(routes!(get_extension_groups, create_extension_group_api))
|
||||
.routes(routes!(
|
||||
get_extension_group_api,
|
||||
update_extension_group_api,
|
||||
delete_extension_group_api
|
||||
))
|
||||
.routes(routes!(
|
||||
add_extension_to_group_api,
|
||||
remove_extension_from_group_api
|
||||
))
|
||||
.routes(routes!(download_browser_api))
|
||||
.routes(routes!(get_browser_versions))
|
||||
.routes(routes!(check_browser_downloaded))
|
||||
.split_for_parts();
|
||||
routes
|
||||
|
||||
// The two paths that carry an extension payload, kept apart so the raised
|
||||
// body limit reaches them and nothing else. Axum's 2 MiB default is smaller
|
||||
// than plenty of real `.crx` files, and a create that 413s before the
|
||||
// handler runs is indistinguishable from a broken endpoint. The GET and
|
||||
// DELETE on these paths ride along because a limit has to be attached per
|
||||
// path, and neither reads a body.
|
||||
let (extension_payload_routes, _) = OpenApiRouter::new()
|
||||
.routes(routes!(get_extensions, create_extension_api))
|
||||
.routes(routes!(
|
||||
get_extension_api,
|
||||
update_extension_api,
|
||||
delete_extension_api
|
||||
))
|
||||
.split_for_parts();
|
||||
|
||||
routes.merge(extension_payload_routes.layer(DefaultBodyLimit::max(64 * 1024 * 1024)))
|
||||
}
|
||||
|
||||
// Terms and Conditions check middleware
|
||||
@@ -1140,6 +1234,11 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|
||||
|| lower.contains("not supported on your platform")
|
||||
|| lower.contains("is not downloaded")
|
||||
|| lower.contains("terms and conditions")
|
||||
// Extension-group compatibility: a group holding a Firefox-only add-on,
|
||||
// or a browser that takes no extensions at all. Both are a caller pairing
|
||||
// two things that don't go together, not a fault of this machine.
|
||||
|| lower.contains("is not compatible with")
|
||||
|| lower.contains("not supported for browser")
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
} else {
|
||||
@@ -1509,6 +1608,27 @@ async fn update_profile(
|
||||
} else {
|
||||
Some(extension_group_id)
|
||||
};
|
||||
// Assigning a group that does not exist, or one holding an extension this
|
||||
// profile's browser cannot load, fails at launch instead of here unless it
|
||||
// is checked now — which is what the Tauri and MCP paths already do.
|
||||
if let Some(group_id) = ext_group.as_deref() {
|
||||
let browser = {
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(manager_error_response)?;
|
||||
profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.map(|p| p.browser.clone())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("Profile with ID '{id}' not found"),
|
||||
)
|
||||
})?
|
||||
};
|
||||
with_extension_manager(|mgr| mgr.validate_group_compatibility(group_id, &browser))?;
|
||||
}
|
||||
if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) {
|
||||
return Err(manager_error_response(e));
|
||||
}
|
||||
@@ -2255,44 +2375,214 @@ async fn delete_vpn(
|
||||
|
||||
// Extension API endpoints
|
||||
|
||||
/// Take the extension store once, in one place, so a poisoned lock answers 500
|
||||
/// instead of panicking the request thread, and every handler classifies the
|
||||
/// manager's `{"code": ...}` errors identically.
|
||||
fn with_extension_manager<T>(
|
||||
action: impl FnOnce(
|
||||
&crate::extension_manager::ExtensionManager,
|
||||
) -> Result<T, Box<dyn std::error::Error>>,
|
||||
) -> Result<T, (StatusCode, String)> {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER
|
||||
.lock()
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"extension manager unavailable".to_string(),
|
||||
)
|
||||
})?;
|
||||
action(&manager).map_err(manager_error_response)
|
||||
}
|
||||
|
||||
/// The payload an extension write carries, once the request body has been
|
||||
/// reduced to the single source it is allowed to name.
|
||||
#[derive(Debug)]
|
||||
enum ExtensionSource {
|
||||
Upload {
|
||||
file_name: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
LocalPath {
|
||||
path: std::path::PathBuf,
|
||||
link: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn extension_request_error(code: &str) -> (StatusCode, String) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
serde_json::json!({ "code": code }).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reduce a create/update body to the one source it names. `Ok(None)` means it
|
||||
/// named none, which only an update (a plain rename) may do.
|
||||
fn resolve_extension_source(
|
||||
file_name: Option<String>,
|
||||
file_data_base64: Option<String>,
|
||||
source_path: Option<String>,
|
||||
link: Option<bool>,
|
||||
) -> Result<Option<ExtensionSource>, (StatusCode, String)> {
|
||||
use base64::Engine as _;
|
||||
|
||||
let upload = match (file_name, file_data_base64) {
|
||||
(Some(name), Some(encoded)) => Some((name, encoded)),
|
||||
(None, None) => None,
|
||||
// Half an upload is not a source: honouring it would mean storing an empty
|
||||
// payload, or inventing a file name and with it a file type.
|
||||
_ => return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
|
||||
};
|
||||
let path = source_path.filter(|p| !p.trim().is_empty());
|
||||
let link = link.unwrap_or(false);
|
||||
|
||||
match (upload, path) {
|
||||
// Two sources is as unanswerable as none: there is no rule for which one
|
||||
// the caller meant, so neither is guessed at.
|
||||
(Some(_), Some(_)) => Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
|
||||
(Some((file_name, encoded)), None) => {
|
||||
if link {
|
||||
// Linking means "keep loading the folder where it already is". An
|
||||
// uploaded archive has no folder on this machine to point at.
|
||||
return Err(extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY"));
|
||||
}
|
||||
let data = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| extension_request_error("EXTENSION_INVALID_BASE64"))?;
|
||||
Ok(Some(ExtensionSource::Upload { file_name, data }))
|
||||
}
|
||||
(None, Some(path)) => Ok(Some(ExtensionSource::LocalPath {
|
||||
path: std::path::PathBuf::from(path),
|
||||
link,
|
||||
})),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/extensions",
|
||||
responses(
|
||||
(status = 200, description = "List of extensions"),
|
||||
(status = 200, description = "List of extensions", body = Vec<crate::extension_manager::Extension>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn get_extensions(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<crate::extension_manager::Extension>>, StatusCode> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.list_extensions()
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
) -> Result<Json<Vec<crate::extension_manager::Extension>>, (StatusCode, String)> {
|
||||
with_extension_manager(|mgr| mgr.list_extensions()).map(Json)
|
||||
}
|
||||
|
||||
/// Add an extension.
|
||||
///
|
||||
/// The body names exactly one source:
|
||||
/// - `file_name` plus `file_data_base64` uploads a `.crx`/`.zip`.
|
||||
/// - `source_path` reads a `.crx`/`.zip`, or packs an unpacked extension
|
||||
/// directory, from the machine running Donut. `link: true` loads that
|
||||
/// directory in place instead, so edits apply on the next browser start;
|
||||
/// a linked extension is machine-local and never syncs.
|
||||
///
|
||||
/// `name` may be omitted: the manifest's own name is preferred anyway, and it
|
||||
/// is only an error when the manifest has none either.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/extension-groups",
|
||||
post,
|
||||
path = "/v1/extensions",
|
||||
request_body = CreateExtensionRequest,
|
||||
responses(
|
||||
(status = 200, description = "List of extension groups"),
|
||||
(status = 201, description = "Extension added", body = crate::extension_manager::Extension),
|
||||
(status = 400, description = "No source, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "source_path does not exist"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn get_extension_groups(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, StatusCode> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.list_groups()
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
async fn create_extension_api(
|
||||
Json(request): Json<CreateExtensionRequest>,
|
||||
) -> Result<(StatusCode, Json<crate::extension_manager::Extension>), (StatusCode, String)> {
|
||||
let source = resolve_extension_source(
|
||||
request.file_name,
|
||||
request.file_data_base64,
|
||||
request.source_path,
|
||||
request.link,
|
||||
)?
|
||||
.ok_or_else(|| extension_request_error("EXTENSION_SOURCE_REQUIRED"))?;
|
||||
let name = request.name.unwrap_or_default();
|
||||
|
||||
let extension = with_extension_manager(|mgr| match source {
|
||||
ExtensionSource::Upload { file_name, data } => mgr.add_extension(name, file_name, data),
|
||||
ExtensionSource::LocalPath { path, link } => mgr.add_extension_from_path(name, &path, link),
|
||||
})?;
|
||||
Ok((StatusCode::CREATED, Json(extension)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/extensions/{id}",
|
||||
params(("id" = String, Path, description = "Extension ID")),
|
||||
responses(
|
||||
(status = 200, description = "Extension", body = crate::extension_manager::Extension),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn get_extension_api(
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
|
||||
with_extension_manager(|mgr| mgr.get_extension(&id)).map(Json)
|
||||
}
|
||||
|
||||
/// Replace an extension's payload, rename it, or both.
|
||||
///
|
||||
/// Sources are the same as on create, and every field is optional — but a body
|
||||
/// carrying neither a name nor a source asks for nothing and is refused rather
|
||||
/// than answered with an unchanged extension.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v1/extensions/{id}",
|
||||
params(("id" = String, Path, description = "Extension ID")),
|
||||
request_body = UpdateExtensionRequest,
|
||||
responses(
|
||||
(status = 200, description = "Extension updated", body = crate::extension_manager::Extension),
|
||||
(status = 400, description = "Nothing to change, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension not found, or source_path does not exist"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn update_extension_api(
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<UpdateExtensionRequest>,
|
||||
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
|
||||
let name = request.name;
|
||||
let source = resolve_extension_source(
|
||||
request.file_name,
|
||||
request.file_data_base64,
|
||||
request.source_path,
|
||||
request.link,
|
||||
)?;
|
||||
if name.is_none() && source.is_none() {
|
||||
return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED"));
|
||||
}
|
||||
|
||||
with_extension_manager(|mgr| match source {
|
||||
Some(ExtensionSource::Upload { file_name, data }) => {
|
||||
mgr.update_extension(&id, name, Some(file_name), Some(data))
|
||||
}
|
||||
Some(ExtensionSource::LocalPath { path, link }) => {
|
||||
mgr.update_extension_from_path(&id, name, &path, link)
|
||||
}
|
||||
None => mgr.update_extension(&id, name, None, None),
|
||||
})
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -2312,11 +2602,88 @@ async fn delete_extension_api(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_extension(&state.app_handle, &id)
|
||||
with_extension_manager(|mgr| mgr.delete_extension(&state.app_handle, &id))
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(manager_error_response)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/extension-groups",
|
||||
responses(
|
||||
(status = 200, description = "List of extension groups", body = Vec<crate::extension_manager::ExtensionGroup>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn get_extension_groups(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, (StatusCode, String)> {
|
||||
with_extension_manager(|mgr| mgr.list_groups()).map(Json)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/extension-groups",
|
||||
request_body = CreateExtensionGroupRequest,
|
||||
responses(
|
||||
(status = 201, description = "Extension group created", body = crate::extension_manager::ExtensionGroup),
|
||||
(status = 400, description = "Empty or duplicate name"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn create_extension_group_api(
|
||||
Json(request): Json<CreateExtensionGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<crate::extension_manager::ExtensionGroup>), (StatusCode, String)> {
|
||||
let group = with_extension_manager(|mgr| mgr.create_group(request.name))?;
|
||||
Ok((StatusCode::CREATED, Json(group)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/extension-groups/{id}",
|
||||
params(("id" = String, Path, description = "Extension Group ID")),
|
||||
responses(
|
||||
(status = 200, description = "Extension group", body = crate::extension_manager::ExtensionGroup),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension group not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn get_extension_group_api(
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
|
||||
with_extension_manager(|mgr| mgr.get_group(&id)).map(Json)
|
||||
}
|
||||
|
||||
/// Rename a group, replace its whole membership list, or both. `extension_ids`
|
||||
/// is a replacement, not an addition — omit it to leave membership alone.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v1/extension-groups/{id}",
|
||||
params(("id" = String, Path, description = "Extension Group ID")),
|
||||
request_body = UpdateExtensionGroupRequest,
|
||||
responses(
|
||||
(status = 200, description = "Extension group updated", body = crate::extension_manager::ExtensionGroup),
|
||||
(status = 400, description = "Empty or duplicate name"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension group not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn update_extension_group_api(
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<UpdateExtensionGroupRequest>,
|
||||
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
|
||||
with_extension_manager(|mgr| mgr.update_group(&id, request.name, request.extension_ids)).map(Json)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -2336,11 +2703,55 @@ async fn delete_extension_group_api(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_group(&state.app_handle, &id)
|
||||
with_extension_manager(|mgr| mgr.delete_group(&state.app_handle, &id))
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(manager_error_response)
|
||||
}
|
||||
|
||||
/// Add one extension to a group. Adding a member it already has is a no-op,
|
||||
/// not an error, so a client re-running its setup converges.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Extension Group ID"),
|
||||
("extension_id" = String, Path, description = "Extension ID to add"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Extension group with the extension added", body = crate::extension_manager::ExtensionGroup),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension or extension group not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn add_extension_to_group_api(
|
||||
Path((id, extension_id)): Path<(String, String)>,
|
||||
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
|
||||
with_extension_manager(|mgr| mgr.add_extension_to_group(&id, &extension_id)).map(Json)
|
||||
}
|
||||
|
||||
/// Remove one extension from a group. The extension itself is untouched.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Extension Group ID"),
|
||||
("extension_id" = String, Path, description = "Extension ID to remove"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Extension group with the extension removed", body = crate::extension_manager::ExtensionGroup),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension group not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn remove_extension_from_group_api(
|
||||
Path((id, extension_id)): Path<(String, String)>,
|
||||
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
|
||||
with_extension_manager(|mgr| mgr.remove_extension_from_group(&id, &extension_id)).map(Json)
|
||||
}
|
||||
|
||||
// API Handler - Run Profile with Remote Debugging
|
||||
@@ -4304,6 +4715,21 @@ mod tests {
|
||||
(Method::GET, "/v1/remote-hours"),
|
||||
// A run id is required; the collection DELETE is not a route.
|
||||
(Method::DELETE, "/v1/cookie-bot/runs/"),
|
||||
// Extension writes touch this machine's own store. They start no
|
||||
// browser, so metering them would spend an automation client's quota on
|
||||
// uploading a `.crx`.
|
||||
(Method::POST, "/v1/extensions"),
|
||||
(Method::PUT, "/v1/extensions/extension-id"),
|
||||
(Method::DELETE, "/v1/extensions/extension-id"),
|
||||
(Method::POST, "/v1/extension-groups"),
|
||||
(
|
||||
Method::POST,
|
||||
"/v1/extension-groups/group-id/extensions/extension-id",
|
||||
),
|
||||
(
|
||||
Method::DELETE,
|
||||
"/v1/extension-groups/group-id/extensions/extension-id",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
!is_automation_request(&method, path),
|
||||
@@ -4642,6 +5068,44 @@ mod tests {
|
||||
"{field} must be optional on the quota, required list: {quota:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// An extension arrives either as an upload or as a path, and its name is
|
||||
// usually read from the manifest. Marking any one of these required would
|
||||
// make a generated client send a field that contradicts the source it
|
||||
// actually has.
|
||||
for request in ["CreateExtensionRequest", "UpdateExtensionRequest"] {
|
||||
let fields = schema_required(&spec, request);
|
||||
assert!(
|
||||
fields.is_empty(),
|
||||
"every field of {request} must be optional, required list: {fields:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let update_extension_group = schema_required(&spec, "UpdateExtensionGroupRequest");
|
||||
for field in ["name", "extension_ids"] {
|
||||
assert!(
|
||||
!update_extension_group.iter().any(|f| f == field),
|
||||
"{field} must be optional on a group update, required list: {update_extension_group:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A group cannot be created without one.
|
||||
let create_extension_group = schema_required(&spec, "CreateExtensionGroupRequest");
|
||||
assert!(
|
||||
create_extension_group.iter().any(|f| f == "name"),
|
||||
"name is required to create a group, required list: {create_extension_group:?}"
|
||||
);
|
||||
|
||||
// `linked_path` is only set for an extension loaded in place, and every
|
||||
// extension stored before unpacked support has neither it nor a
|
||||
// `source_kind`, so a required marking would break reading them back.
|
||||
let extension = schema_required(&spec, "Extension");
|
||||
for field in ["linked_path", "version", "description", "author"] {
|
||||
assert!(
|
||||
!extension.iter().any(|f| f == field),
|
||||
"{field} must be optional on an extension, required list: {extension:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4686,6 +5150,174 @@ mod tests {
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_extension_upload_is_the_callers_problem_not_a_server_fault() {
|
||||
// Every one of these is something about the request: the wrong file type, a
|
||||
// folder with no readable manifest, a link asked for on a file. Answering
|
||||
// 500 would tell a client to retry a body that can never be accepted.
|
||||
for code in [
|
||||
"EXTENSION_UNSUPPORTED_FILE_TYPE",
|
||||
"EXTENSION_NOT_A_DIRECTORY",
|
||||
"EXTENSION_MANIFEST_MISSING",
|
||||
"EXTENSION_MANIFEST_INVALID",
|
||||
"EXTENSION_DIR_TOO_LARGE",
|
||||
"EXTENSION_PATH_HAS_COMMA",
|
||||
"EXTENSION_LINK_REQUIRES_DIRECTORY",
|
||||
"NAME_CANNOT_BE_EMPTY",
|
||||
] {
|
||||
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{code} must be a 400");
|
||||
assert!(body.contains(code), "{code} must reach the caller");
|
||||
}
|
||||
|
||||
// A path that is not there is the one refusal that names a missing thing.
|
||||
let (status, _) =
|
||||
manager_error_response(serde_json::json!({ "code": "EXTENSION_DIR_NOT_FOUND" }).to_string());
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incompatible_extension_group_is_refused_rather_than_reported_as_broken() {
|
||||
// `validate_group_compatibility` answers in prose, so both of its refusals
|
||||
// fell through to 500 — which reads as "this server is broken" for what is
|
||||
// really a group the caller cannot put on that profile.
|
||||
for message in [
|
||||
"Extension 'uBlock' (crx) is not compatible with gecko browsers",
|
||||
"Extensions are not supported for browser 'firefox'",
|
||||
] {
|
||||
let (status, body) = manager_error_response(message);
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{message} must be a 400");
|
||||
assert_eq!(body, message, "the diagnostic must reach the caller");
|
||||
}
|
||||
|
||||
// A group that is simply absent stays a 404.
|
||||
let (status, _) = manager_error_response("Extension group with id 'gone' not found");
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extension_write_names_exactly_one_source() {
|
||||
use base64::Engine as _;
|
||||
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(b"PK\x03\x04");
|
||||
|
||||
// An upload decodes to the bytes the caller sent.
|
||||
match resolve_extension_source(
|
||||
Some("ublock.crx".to_string()),
|
||||
Some(encoded.clone()),
|
||||
None,
|
||||
None,
|
||||
) {
|
||||
Ok(Some(ExtensionSource::Upload { file_name, data })) => {
|
||||
assert_eq!(file_name, "ublock.crx");
|
||||
assert_eq!(data, b"PK\x03\x04");
|
||||
}
|
||||
other => panic!("an upload must resolve to its bytes: {other:?}"),
|
||||
}
|
||||
|
||||
// A path is taken as-is, and `link` rides with it.
|
||||
match resolve_extension_source(None, None, Some("/srv/ext".to_string()), Some(true)) {
|
||||
Ok(Some(ExtensionSource::LocalPath { path, link })) => {
|
||||
assert_eq!(path, std::path::PathBuf::from("/srv/ext"));
|
||||
assert!(link);
|
||||
}
|
||||
other => panic!("a path must resolve to a path: {other:?}"),
|
||||
}
|
||||
|
||||
// Naming both sources has no answer: neither one is guessed at.
|
||||
let both = resolve_extension_source(
|
||||
Some("ublock.crx".to_string()),
|
||||
Some(encoded.clone()),
|
||||
Some("/srv/ext".to_string()),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
both.expect_err("two sources must be refused"),
|
||||
extension_request_error("EXTENSION_SOURCE_REQUIRED")
|
||||
);
|
||||
|
||||
// Half an upload is not a source. Storing an empty payload, or inventing a
|
||||
// file name, would both produce an extension that never loads.
|
||||
for half in [
|
||||
(Some("ublock.crx".to_string()), None),
|
||||
(None, Some(encoded.clone())),
|
||||
] {
|
||||
let (file_name, data) = half;
|
||||
assert_eq!(
|
||||
resolve_extension_source(file_name, data, None, None)
|
||||
.expect_err("half an upload must be refused"),
|
||||
extension_request_error("EXTENSION_SOURCE_REQUIRED")
|
||||
);
|
||||
}
|
||||
|
||||
// Linking means "load the folder where it is"; an upload has no folder.
|
||||
assert_eq!(
|
||||
resolve_extension_source(
|
||||
Some("ublock.crx".to_string()),
|
||||
Some(encoded),
|
||||
None,
|
||||
Some(true)
|
||||
)
|
||||
.expect_err("a linked upload must be refused"),
|
||||
extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY")
|
||||
);
|
||||
|
||||
// Undecodable base64 is named as such rather than reaching the store as an
|
||||
// empty or truncated archive.
|
||||
assert_eq!(
|
||||
resolve_extension_source(
|
||||
Some("ublock.crx".to_string()),
|
||||
Some("not base64!!".to_string()),
|
||||
None,
|
||||
None
|
||||
)
|
||||
.expect_err("undecodable base64 must be refused"),
|
||||
extension_request_error("EXTENSION_INVALID_BASE64")
|
||||
);
|
||||
|
||||
// No source at all is legal on the wire — it is a rename, and only the
|
||||
// update handler accepts it.
|
||||
assert!(resolve_extension_source(None, None, None, None)
|
||||
.expect("naming no source is not an error here")
|
||||
.is_none());
|
||||
|
||||
// An empty string is not a path.
|
||||
assert!(
|
||||
resolve_extension_source(None, None, Some(" ".to_string()), None)
|
||||
.expect("a blank path is not an error here")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extension_update_that_asks_for_nothing_is_refused() {
|
||||
// Every field is optional, so an empty body parses. Answering 200 with an
|
||||
// untouched extension would tell a client its rename landed.
|
||||
let empty: UpdateExtensionRequest =
|
||||
serde_json::from_str("{}").expect("an empty update body must deserialize");
|
||||
assert!(empty.name.is_none());
|
||||
assert!(resolve_extension_source(
|
||||
empty.file_name,
|
||||
empty.file_data_base64,
|
||||
empty.source_path,
|
||||
empty.link
|
||||
)
|
||||
.expect("no source is not an error on update")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_an_extension_needs_neither_a_name_nor_a_link() {
|
||||
// The manifest's own name is preferred, so a caller uploading a `.crx`
|
||||
// sends two fields and nothing else.
|
||||
let minimal: CreateExtensionRequest =
|
||||
serde_json::from_str(r#"{"file_name": "ublock.crx", "file_data_base64": "UEsDBA=="}"#)
|
||||
.expect("a minimal create body must deserialize");
|
||||
assert!(minimal.name.is_none());
|
||||
assert!(minimal.link.is_none());
|
||||
assert_eq!(minimal.file_name.as_deref(), Some("ublock.crx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
|
||||
// Without this route `run-remote` hands back a session id that nothing
|
||||
@@ -4763,6 +5395,7 @@ mod tests {
|
||||
"/v1/extension-groups",
|
||||
"/v1/extensions/{id}",
|
||||
"/v1/extension-groups/{id}",
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
"/v1/profiles/import",
|
||||
"/v1/profiles/import/detect",
|
||||
"/v1/proxies/import",
|
||||
@@ -4801,6 +5434,26 @@ mod tests {
|
||||
("/v1/cookie-bot/runs", "get"),
|
||||
("/v1/cookie-bot/runs", "post"),
|
||||
("/v1/cookie-bot/runs/{run_id}", "delete"),
|
||||
// The extension surface is five paths carrying eleven methods, so it is
|
||||
// the densest place in the router for one `routes!` to swallow another.
|
||||
("/v1/extensions", "get"),
|
||||
("/v1/extensions", "post"),
|
||||
("/v1/extensions/{id}", "get"),
|
||||
("/v1/extensions/{id}", "put"),
|
||||
("/v1/extensions/{id}", "delete"),
|
||||
("/v1/extension-groups", "get"),
|
||||
("/v1/extension-groups", "post"),
|
||||
("/v1/extension-groups/{id}", "get"),
|
||||
("/v1/extension-groups/{id}", "put"),
|
||||
("/v1/extension-groups/{id}", "delete"),
|
||||
(
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
"post",
|
||||
),
|
||||
(
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
"delete",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
paths[path].get(method).is_some(),
|
||||
@@ -4865,6 +5518,14 @@ mod tests {
|
||||
"RemoteHoursQuota",
|
||||
"RemoteHoursMember",
|
||||
"RemoteHoursBreakdown",
|
||||
// Neither extension type was registered while only the list and delete
|
||||
// routes existed, so every extension response resolved to nothing.
|
||||
"Extension",
|
||||
"ExtensionGroup",
|
||||
"CreateExtensionRequest",
|
||||
"UpdateExtensionRequest",
|
||||
"CreateExtensionGroupRequest",
|
||||
"UpdateExtensionGroupRequest",
|
||||
] {
|
||||
assert!(
|
||||
spec["components"]["schemas"][schema]["properties"].is_object(),
|
||||
@@ -4895,6 +5556,15 @@ mod tests {
|
||||
"RemoteSessionState",
|
||||
),
|
||||
("/v1/remote-hours", "get", "200", "RemoteHoursQuota"),
|
||||
("/v1/extensions", "post", "201", "Extension"),
|
||||
("/v1/extensions/{id}", "put", "200", "Extension"),
|
||||
("/v1/extension-groups", "post", "201", "ExtensionGroup"),
|
||||
(
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
"post",
|
||||
"200",
|
||||
"ExtensionGroup",
|
||||
),
|
||||
] {
|
||||
let reference =
|
||||
&paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"];
|
||||
@@ -4905,6 +5575,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Both extension lists declared a 200 with no body at all, so a generated
|
||||
// client got a call that returns nothing from a route that returns
|
||||
// everything. Each must be an array of the same component its single-item
|
||||
// route resolves to.
|
||||
for (path, schema) in [
|
||||
("/v1/extensions", "Extension"),
|
||||
("/v1/extension-groups", "ExtensionGroup"),
|
||||
] {
|
||||
let item = &paths[path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
["items"]["$ref"];
|
||||
assert_eq!(
|
||||
item.as_str(),
|
||||
Some(format!("#/components/schemas/{schema}").as_str()),
|
||||
"get {path} 200 is not a list of {schema}: {item:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The presets a client may choose from must never carry the behaviour they
|
||||
// expand to. A site list, a dwell range or a step programme appearing here
|
||||
// would mean the browsing model had leaked out of the server.
|
||||
@@ -4967,5 +5654,28 @@ mod tests {
|
||||
"a schedule write must not declare a 429: {method}"
|
||||
);
|
||||
}
|
||||
|
||||
// Extension writes launch nothing and lease nothing, so the limiter never
|
||||
// sees them. A declared 429 would be a status the server cannot produce.
|
||||
for (path, method) in [
|
||||
("/v1/extensions", "post"),
|
||||
("/v1/extensions/{id}", "put"),
|
||||
("/v1/extensions/{id}", "delete"),
|
||||
("/v1/extension-groups", "post"),
|
||||
("/v1/extension-groups/{id}", "put"),
|
||||
(
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
"post",
|
||||
),
|
||||
(
|
||||
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||
"delete",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
paths[path][method]["responses"].get("429").is_none(),
|
||||
"an extension write must not declare a 429: {method} {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1328,6 +1328,13 @@ impl BrowserRunner {
|
||||
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
|
||||
}
|
||||
|
||||
// The browser held these open for the life of the process; nothing reads
|
||||
// them once it has exited, and they are plaintext extension code sitting
|
||||
// on real disk even for an ephemeral profile.
|
||||
crate::extension_manager::ExtensionManager::cleanup_unpacked_for_profile(
|
||||
&profile.id.to_string(),
|
||||
);
|
||||
|
||||
log::info!(
|
||||
"Wayfern process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
|
||||
@@ -804,6 +804,13 @@ impl CloudAuthManager {
|
||||
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||
/// (sync token); per-feature access uses the capability helpers.
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled()
|
||||
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
self.entitlements().await.map(|e| e.active).unwrap_or(false)
|
||||
}
|
||||
|
||||
|
||||
+1134
-204
File diff suppressed because it is too large
Load Diff
@@ -188,9 +188,10 @@ use profile_importer::{
|
||||
};
|
||||
|
||||
use extension_manager::{
|
||||
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
|
||||
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
|
||||
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
|
||||
add_extension, add_extension_to_group, add_unpacked_extension, assign_extension_group_to_profile,
|
||||
create_extension_group, delete_extension, delete_extension_group,
|
||||
get_extension_group_for_profile, get_extension_icon, list_extension_groups, list_extensions,
|
||||
remove_extension_from_group, update_extension, update_extension_from_path,
|
||||
update_extension_group,
|
||||
};
|
||||
|
||||
@@ -2750,7 +2751,9 @@ pub fn run_with_builder(
|
||||
list_extensions,
|
||||
get_extension_icon,
|
||||
add_extension,
|
||||
add_unpacked_extension,
|
||||
update_extension,
|
||||
update_extension_from_path,
|
||||
delete_extension,
|
||||
list_extension_groups,
|
||||
create_extension_group,
|
||||
|
||||
+262
-2
@@ -1307,6 +1307,33 @@ impl McpServer {
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "add_extension".to_string(),
|
||||
description: "Add a managed browser extension from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so edits to it apply on the next browser start and the extension is machine-local and never synced. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string", "description": "Path on the machine running Donut to a .crx/.zip file or to an unpacked extension folder" },
|
||||
"name": { "type": "string", "description": "Display name, used only when the manifest carries no name of its own" },
|
||||
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_extension".to_string(),
|
||||
description: "Rename a managed extension and/or replace its payload from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so the extension becomes machine-local and never syncs. At least one of name or path must be given. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"extension_id": { "type": "string", "description": "The extension ID to update" },
|
||||
"name": { "type": "string", "description": "New display name" },
|
||||
"path": { "type": "string", "description": "Path on the machine running Donut to the .crx/.zip file or unpacked extension folder to replace the payload with" },
|
||||
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
|
||||
},
|
||||
"required": ["extension_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "create_extension_group".to_string(),
|
||||
description: "Create a new extension group. Requires Pro subscription.".to_string(),
|
||||
@@ -1318,6 +1345,47 @@ impl McpServer {
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_extension_group".to_string(),
|
||||
description: "Rename an extension group and/or replace its membership with an exact list of extension IDs. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "The extension group ID to update" },
|
||||
"name": { "type": "string", "description": "New name for the extension group" },
|
||||
"extension_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "The complete set of extension IDs the group should contain, replacing the current membership"
|
||||
}
|
||||
},
|
||||
"required": ["group_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "add_extension_to_group".to_string(),
|
||||
description: "Add an extension to an extension group. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "The extension group ID" },
|
||||
"extension_id": { "type": "string", "description": "The extension ID to add to the group" }
|
||||
},
|
||||
"required": ["group_id", "extension_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "remove_extension_from_group".to_string(),
|
||||
description: "Remove an extension from an extension group. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "The extension group ID" },
|
||||
"extension_id": { "type": "string", "description": "The extension ID to remove from the group" }
|
||||
},
|
||||
"required": ["group_id", "extension_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "delete_extension".to_string(),
|
||||
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
|
||||
@@ -2215,7 +2283,12 @@ impl McpServer {
|
||||
// Extension management
|
||||
"list_extensions" => self.handle_list_extensions().await,
|
||||
"list_extension_groups" => self.handle_list_extension_groups().await,
|
||||
"add_extension" => self.handle_add_extension(arguments).await,
|
||||
"update_extension" => self.handle_update_extension(arguments).await,
|
||||
"create_extension_group" => self.handle_create_extension_group(arguments).await,
|
||||
"update_extension_group" => self.handle_update_extension_group(arguments).await,
|
||||
"add_extension_to_group" => self.handle_add_extension_to_group(arguments).await,
|
||||
"remove_extension_from_group" => self.handle_remove_extension_from_group(arguments).await,
|
||||
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
|
||||
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
|
||||
"assign_extension_group_to_profile" => {
|
||||
@@ -4402,6 +4475,88 @@ impl McpServer {
|
||||
Ok(serde_json::to_value(groups).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_add_extension(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let path = arguments
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: path".to_string(),
|
||||
})?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let link = arguments
|
||||
.get("link")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let extension = mgr
|
||||
.add_extension_from_path(name, std::path::Path::new(path), link)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to add extension: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(extension).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_update_extension(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let extension_id = arguments
|
||||
.get("extension_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: extension_id".to_string(),
|
||||
})?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let path = arguments.get("path").and_then(|v| v.as_str());
|
||||
if name.is_none() && path.is_none() {
|
||||
return Err(McpError {
|
||||
code: -32602,
|
||||
message: "Provide at least one of: name, path".to_string(),
|
||||
});
|
||||
}
|
||||
let link = arguments
|
||||
.get("link")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let extension = match path {
|
||||
Some(path) => {
|
||||
mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link)
|
||||
}
|
||||
None => mgr.update_extension(extension_id, name, None, None),
|
||||
}
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update extension: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(extension).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_create_extension_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
@@ -4427,6 +4582,106 @@ impl McpServer {
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_update_extension_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let group_id = arguments
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: group_id".to_string(),
|
||||
})?;
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string);
|
||||
let extension_ids = arguments
|
||||
.get("extension_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|ids| {
|
||||
ids
|
||||
.iter()
|
||||
.filter_map(|id| id.as_str().map(str::to_string))
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = mgr
|
||||
.update_group(group_id, name, extension_ids)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update extension group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_add_extension_to_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = mgr
|
||||
.add_extension_to_group(group_id, extension_id)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to add extension to group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_remove_extension_from_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = mgr
|
||||
.remove_extension_from_group(group_id, extension_id)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to remove extension from group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
fn group_and_extension_ids(arguments: &serde_json::Value) -> Result<(&str, &str), McpError> {
|
||||
let group_id = arguments
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: group_id".to_string(),
|
||||
})?;
|
||||
let extension_id = arguments
|
||||
.get("extension_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: extension_id".to_string(),
|
||||
})?;
|
||||
Ok((group_id, extension_id))
|
||||
}
|
||||
|
||||
async fn handle_delete_extension_mcp(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
@@ -6035,9 +6290,9 @@ mod tests {
|
||||
let server = McpServer::new();
|
||||
let tools = server.get_tools();
|
||||
|
||||
// Should have at least 54 tools (34 + 7 browser interaction + 13 remote
|
||||
// Should have at least 59 tools (39 + 7 browser interaction + 13 remote
|
||||
// fleet and cookie-bot tools)
|
||||
assert!(tools.len() >= 54);
|
||||
assert!(tools.len() >= 59);
|
||||
|
||||
// Names are the contract an MCP client is written against, so a duplicate
|
||||
// silently shadows one of the two in dispatch and the tool that loses is
|
||||
@@ -6092,7 +6347,12 @@ mod tests {
|
||||
// Extension tools
|
||||
assert!(tool_names.contains(&"list_extensions"));
|
||||
assert!(tool_names.contains(&"list_extension_groups"));
|
||||
assert!(tool_names.contains(&"add_extension"));
|
||||
assert!(tool_names.contains(&"update_extension"));
|
||||
assert!(tool_names.contains(&"create_extension_group"));
|
||||
assert!(tool_names.contains(&"update_extension_group"));
|
||||
assert!(tool_names.contains(&"add_extension_to_group"));
|
||||
assert!(tool_names.contains(&"remove_extension_from_group"));
|
||||
assert!(tool_names.contains(&"delete_extension"));
|
||||
assert!(tool_names.contains(&"delete_extension_group"));
|
||||
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
||||
|
||||
@@ -2084,6 +2084,13 @@ impl SyncEngine {
|
||||
manager.get_extension(ext_id).ok()
|
||||
};
|
||||
|
||||
// A linked extension is an absolute path on this machine with no payload in
|
||||
// the store. Uploading it would publish metadata another device could never
|
||||
// resolve, so it stays local whatever queued this run.
|
||||
if local_ext.as_ref().is_some_and(|e| e.is_linked()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remote_key = format!("extensions/{}.json", ext_id);
|
||||
let stat = self.client.stat(&remote_key).await?;
|
||||
|
||||
@@ -3251,7 +3258,9 @@ pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) ->
|
||||
manager
|
||||
.get_extension(ext_id)
|
||||
.ok()
|
||||
.map(|e| e.sync_enabled)
|
||||
// A linked extension has no binary to hand the other device, only a
|
||||
// path that means nothing there, so the cascade must not pick it up.
|
||||
.map(|e| e.sync_enabled || e.is_linked())
|
||||
.unwrap_or(true)
|
||||
};
|
||||
if !already_synced {
|
||||
@@ -3983,7 +3992,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
|
||||
.map_err(|e| format!("Failed to list extensions: {e}"))?
|
||||
};
|
||||
for ext in &exts {
|
||||
if !ext.sync_enabled {
|
||||
// Linked extensions are machine-local by definition and are skipped
|
||||
// rather than reported as a failure on every sync setup.
|
||||
if !ext.sync_enabled && !ext.is_linked() {
|
||||
if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await {
|
||||
log::warn!("Failed to enable sync for extension {}: {e}", ext.id);
|
||||
}
|
||||
@@ -4029,6 +4040,11 @@ pub async fn set_extension_sync_enabled(
|
||||
};
|
||||
|
||||
if enabled {
|
||||
// A linked extension is a path on this machine and nothing else; there is
|
||||
// no payload to upload and the path would be meaningless on another device.
|
||||
if ext.is_linked() {
|
||||
return Err(serde_json::json!({ "code": "EXTENSION_LINKED_CANNOT_SYNC" }).to_string());
|
||||
}
|
||||
ensure_sync_configured(&app_handle).await?;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user